From b7cd7e60775fd0c4c4fc83ae2135ec44430f3467 Mon Sep 17 00:00:00 2001 From: cdarninsuang-bamfunds Date: Tue, 25 Aug 2026 10:58:12 +0000 Subject: [PATCH] [JENKINS-60730] Add stale branch, tag, and pull request filter traits Add three SCMSourceTrait extensions that exclude stale heads from GitHub multibranch/org-folder indexing: - StaleBranchFilterTrait: filter branches with no commits in N days - StaleTagFilterTrait: filter tags older than N days - StalePullRequestFilterTrait: filter PRs with no activity in N days Each trait supports an optional include/exclude regex to scope or exempt heads by name, and a dry-run mode that logs what would be filtered without excluding it. StaleBranchFilterTrait never filters the default branch or a GitHub-protected branch, regardless of age. Includes unit tests for each trait. See JENKINS-60730 and jenkinsci/github-branch-source-plugin#263 for prior discussion of this feature. --- .../StaleBranchFilterTrait.java | 221 +++++++++++++ .../StalePullRequestFilterTrait.java | 227 ++++++++++++++ .../StaleTagFilterTrait.java | 195 ++++++++++++ .../github_branch_source/Messages.properties | 4 + .../StaleBranchFilterTrait/config.jelly | 18 ++ .../StaleBranchFilterTrait/help.html | 1 + .../StalePullRequestFilterTrait/config.jelly | 18 ++ .../StalePullRequestFilterTrait/help.html | 2 + .../StaleTagFilterTrait/config.jelly | 18 ++ .../StaleTagFilterTrait/help.html | 2 + .../StaleBranchFilterTraitTest.java | 292 ++++++++++++++++++ .../StalePullRequestFilterTraitTest.java | 259 ++++++++++++++++ .../StaleTagFilterTraitTest.java | 224 ++++++++++++++ 13 files changed, 1481 insertions(+) create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait.java create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait.java create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait.java create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/config.jelly create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/help.html create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/config.jelly create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/help.html create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/config.jelly create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/help.html create mode 100644 src/test/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTraitTest.java create mode 100644 src/test/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTraitTest.java create mode 100644 src/test/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTraitTest.java diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait.java new file mode 100644 index 000000000..271d38bd9 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait.java @@ -0,0 +1,221 @@ +package org.jenkinsci.plugins.github_branch_source; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.util.FormValidation; +import java.io.IOException; +import java.util.Date; +import java.util.regex.Pattern; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.SCMSource; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import jenkins.scm.api.trait.SCMSourceTrait; +import jenkins.scm.api.trait.SCMSourceTraitDescriptor; +import jenkins.scm.impl.trait.Selection; +import org.jenkinsci.Symbol; +import org.kohsuke.github.GHBranch; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; + +/** + * Trait that filters out branches whose last commit is older than a specified number of days. + * Stale branches are excluded from Jenkins indexing so they no longer appear as jobs. + * + *

Optional {@code includeRegex} scopes the filter to only matching branch names. + * Optional {@code excludeRegex} exempts matching branch names from stale filtering entirely. + */ +public class StaleBranchFilterTrait extends SCMSourceTrait { + + /** Number of days after which a branch with no new commits is considered stale. */ + private final int daysStale; + + /** + * If set, stale filtering is only applied to branches whose name matches this regex. + * Branches that do not match are never excluded. + */ + @CheckForNull + private String includeRegex; + + /** + * If set, branches whose name matches this regex are always kept, regardless of age. + */ + @CheckForNull + private String excludeRegex; + + @DataBoundConstructor + public StaleBranchFilterTrait(int daysStale) { + this.daysStale = Math.max(1, daysStale); + } + + public int getDaysStale() { + return daysStale; + } + + @CheckForNull + public String getIncludeRegex() { + return includeRegex; + } + + @DataBoundSetter + public void setIncludeRegex(@CheckForNull String includeRegex) { + this.includeRegex = Util.fixEmptyAndTrim(includeRegex); + } + + @CheckForNull + public String getExcludeRegex() { + return excludeRegex; + } + + @DataBoundSetter + public void setExcludeRegex(@CheckForNull String excludeRegex) { + this.excludeRegex = Util.fixEmptyAndTrim(excludeRegex); + } + + /** + * When {@code true}, stale branches are logged as "WOULD filter" but not actually excluded. + * Lets you preview the impact of the filter before enabling real filtering. + */ + private boolean dryRun; + + public boolean isDryRun() { + return dryRun; + } + + @DataBoundSetter + public void setDryRun(boolean dryRun) { + this.dryRun = dryRun; + } + + @Override + protected void decorateContext(SCMSourceContext context) { + final Pattern includePattern = includeRegex != null ? Pattern.compile(includeRegex) : null; + final Pattern excludePattern = excludeRegex != null ? Pattern.compile(excludeRegex) : null; + + context.withFilter(new SCMHeadFilter() { + // Cache the default branch name so we don't call getDefaultBranch() for every head. + private String defaultBranch = null; + private boolean defaultBranchFetched = false; + + private String getDefaultBranch(GitHubSCMSourceRequest githubRequest) throws IOException { + if (!defaultBranchFetched) { + defaultBranch = githubRequest.getRepository().getDefaultBranch(); + defaultBranchFetched = true; + } + return defaultBranch; + } + + @Override + public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead head) throws IOException { + if (!(request instanceof GitHubSCMSourceRequest) || !(head instanceof BranchSCMHead)) { + return false; + } + GitHubSCMSourceRequest githubRequest = (GitHubSCMSourceRequest) request; + String headName = head.getName(); + + if (includePattern != null && !includePattern.matcher(headName).matches()) { + return false; + } + if (excludePattern != null && excludePattern.matcher(headName).matches()) { + return false; + } + + for (GHBranch branch : githubRequest.getBranches()) { + if (!branch.getName().equals(headName)) { + continue; + } + if (branch.getName().equals(getDefaultBranch(githubRequest))) { + return false; + } + if (branch.isProtected()) { + request.listener() + .getLogger() + .format("%n Won't filter branch %s: it is a protected branch.%n", headName); + return false; + } + Date lastCommitDate = githubRequest + .getRepository() + .getCommit(branch.getSHA1()) + .getCommitDate(); + if (lastCommitDate == null) { + return false; + } + long ageInDays = (System.currentTimeMillis() - lastCommitDate.getTime()) / (1000L * 60 * 60 * 24); + if (ageInDays >= daysStale) { + if (dryRun) { + request.listener() + .getLogger() + .format( + "%n [stale-dry-run] WOULD filter branch %s. Last commit was %d day(s) ago" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return false; + } + request.listener() + .getLogger() + .format( + "%n Won't build branch %s. Last commit was %d day(s) ago" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return true; + } + return false; + } + return false; + } + }); + } + + @Symbol("gitHubStaleBranchFilter") + @Extension + @Selection + public static class DescriptorImpl extends SCMSourceTraitDescriptor { + + @Override + public String getDisplayName() { + return Messages.StaleBranchFilterTrait_DisplayName(); + } + + @Override + public Class getContextClass() { + return GitHubSCMSourceContext.class; + } + + @Override + public Class getSourceClass() { + return GitHubSCMSource.class; + } + + public FormValidation doCheckDaysStale(@QueryParameter int value) { + if (value < 1) { + return FormValidation.error("Days stale must be a positive number."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckIncludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + public FormValidation doCheckExcludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + private FormValidation validateRegex(String value) { + String trimmed = Util.fixEmptyAndTrim(value); + if (trimmed == null) { + return FormValidation.ok(); + } + try { + java.util.regex.Pattern.compile(trimmed); + return FormValidation.ok(); + } catch (java.util.regex.PatternSyntaxException e) { + return FormValidation.error("Invalid regular expression: " + e.getMessage()); + } + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait.java new file mode 100644 index 000000000..20e164591 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait.java @@ -0,0 +1,227 @@ +package org.jenkinsci.plugins.github_branch_source; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.util.FormValidation; +import java.io.IOException; +import java.util.Date; +import java.util.regex.Pattern; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.SCMSource; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import jenkins.scm.api.trait.SCMSourceTrait; +import jenkins.scm.api.trait.SCMSourceTraitDescriptor; +import jenkins.scm.impl.trait.Selection; +import org.jenkinsci.Symbol; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; + +/** + * Trait that filters out pull requests that have had no activity for a specified number of days. + * Staleness is measured against the PR's last-updated timestamp (new commits, comments, reviews, + * labels, etc. all count as activity), which GitHub returns as part of the PR listing — this filter + * makes no additional GitHub API calls. + * Stale pull requests are excluded from Jenkins indexing so they no longer appear as jobs. + * + *

Optional {@code includeRegex} scopes the filter to only matching PR head names (e.g. {@code PR-42}). + * Optional {@code excludeRegex} exempts matching PR head names from stale filtering entirely. + */ +public class StalePullRequestFilterTrait extends SCMSourceTrait { + + /** Number of days of inactivity after which a pull request is considered stale. */ + private final int daysStale; + + /** + * If set, stale filtering is only applied to PRs whose head name matches this regex. + * PRs that do not match are never excluded. + */ + @CheckForNull + private String includeRegex; + + /** + * If set, PRs whose head name matches this regex are always kept, regardless of age. + */ + @CheckForNull + private String excludeRegex; + + @DataBoundConstructor + public StalePullRequestFilterTrait(int daysStale) { + this.daysStale = Math.max(1, daysStale); + } + + public int getDaysStale() { + return daysStale; + } + + @CheckForNull + public String getIncludeRegex() { + return includeRegex; + } + + @DataBoundSetter + public void setIncludeRegex(@CheckForNull String includeRegex) { + this.includeRegex = Util.fixEmptyAndTrim(includeRegex); + } + + @CheckForNull + public String getExcludeRegex() { + return excludeRegex; + } + + @DataBoundSetter + public void setExcludeRegex(@CheckForNull String excludeRegex) { + this.excludeRegex = Util.fixEmptyAndTrim(excludeRegex); + } + + /** + * When {@code true}, stale pull requests are logged as "WOULD filter" but not actually excluded. + * Lets you preview the impact of the filter before enabling real filtering. + */ + private boolean dryRun; + + public boolean isDryRun() { + return dryRun; + } + + @DataBoundSetter + public void setDryRun(boolean dryRun) { + this.dryRun = dryRun; + } + + @Override + protected void decorateContext(SCMSourceContext context) { + final Pattern includePattern = includeRegex != null ? Pattern.compile(includeRegex) : null; + final Pattern excludePattern = excludeRegex != null ? Pattern.compile(excludeRegex) : null; + + context.withFilter(new SCMHeadFilter() { + @Override + public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead head) throws IOException { + if (!(request instanceof GitHubSCMSourceRequest) || !(head instanceof PullRequestSCMHead)) { + return false; + } + GitHubSCMSourceRequest githubRequest = (GitHubSCMSourceRequest) request; + String headName = head.getName(); + int prNumber = ((PullRequestSCMHead) head).getNumber(); + + if (includePattern != null && !includePattern.matcher(headName).matches()) { + return false; + } + if (excludePattern != null && excludePattern.matcher(headName).matches()) { + return false; + } + + request.listener() + .getLogger() + .format( + "%n [StalePRFilter] Checking %s (PR #%d), threshold=%d day(s)%n", + headName, prNumber, daysStale); + boolean prFound = false; + for (GHPullRequest pr : githubRequest.getPullRequests()) { + if (pr.getNumber() != prNumber) { + continue; + } + prFound = true; + + // pr.getUpdatedAt() is already populated from the PR listing response, + // so no additional API call is needed here. + Date lastUpdated = pr.getUpdatedAt(); + if (lastUpdated == null) { + request.listener() + .getLogger() + .format( + "%n [StalePRFilter] Updated-at date is null for PR #%d — skipping filter%n", + prNumber); + return false; + } + + long ageInDays = (System.currentTimeMillis() - lastUpdated.getTime()) / (1000L * 60 * 60 * 24); + request.listener() + .getLogger() + .format( + "%n [StalePRFilter] %s last updated %d day(s) ago (threshold: %d day(s))%n", + headName, ageInDays, daysStale); + if (ageInDays >= daysStale) { + if (dryRun) { + request.listener() + .getLogger() + .format( + "%n [stale-dry-run] WOULD filter pull request %s. Last updated %d day(s) ago" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return false; + } + request.listener() + .getLogger() + .format( + "%n Won't build pull request %s. Last updated %d day(s) ago" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return true; + } + return false; + } + if (!prFound) { + request.listener() + .getLogger() + .format("%n [StalePRFilter] PR #%d not found in getPullRequests() list%n", prNumber); + } + return false; + } + }); + } + + @Symbol("gitHubStalePullRequestFilter") + @Extension + @Selection + public static class DescriptorImpl extends SCMSourceTraitDescriptor { + + @Override + public String getDisplayName() { + return Messages.StalePullRequestFilterTrait_DisplayName(); + } + + @Override + public Class getContextClass() { + return GitHubSCMSourceContext.class; + } + + @Override + public Class getSourceClass() { + return GitHubSCMSource.class; + } + + public FormValidation doCheckDaysStale(@QueryParameter int value) { + if (value < 1) { + return FormValidation.error("Days stale must be a positive number."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckIncludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + public FormValidation doCheckExcludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + private FormValidation validateRegex(String value) { + String trimmed = Util.fixEmptyAndTrim(value); + if (trimmed == null) { + return FormValidation.ok(); + } + try { + Pattern.compile(trimmed); + return FormValidation.ok(); + } catch (java.util.regex.PatternSyntaxException e) { + return FormValidation.error("Invalid regular expression: " + e.getMessage()); + } + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait.java new file mode 100644 index 000000000..b9743ad45 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait.java @@ -0,0 +1,195 @@ +package org.jenkinsci.plugins.github_branch_source; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.util.FormValidation; +import java.io.IOException; +import java.util.regex.Pattern; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.SCMSource; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import jenkins.scm.api.trait.SCMSourceTrait; +import jenkins.scm.api.trait.SCMSourceTraitDescriptor; +import jenkins.scm.impl.trait.Selection; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; + +/** + * Trait that filters out tags whose creation date is older than a specified number of days. + * Stale tags are excluded from Jenkins indexing so they no longer appear as jobs. + * + *

The tag date is read from the already-discovered {@link GitHubTagSCMHead#getTimestamp()} + * (computed once during discovery), so this filter makes no additional GitHub API calls. + * + *

Optional {@code includeRegex} scopes the filter to only matching tag names. + * Optional {@code excludeRegex} exempts matching tag names from stale filtering entirely. + */ +public class StaleTagFilterTrait extends SCMSourceTrait { + + /** Number of days after which a tag is considered stale. */ + private final int daysStale; + + /** + * If set, stale filtering is only applied to tags whose name matches this regex. + * Tags that do not match are never excluded. + */ + @CheckForNull + private String includeRegex; + + /** + * If set, tags whose name matches this regex are always kept, regardless of age. + */ + @CheckForNull + private String excludeRegex; + + @DataBoundConstructor + public StaleTagFilterTrait(int daysStale) { + this.daysStale = Math.max(1, daysStale); + } + + public int getDaysStale() { + return daysStale; + } + + @CheckForNull + public String getIncludeRegex() { + return includeRegex; + } + + @DataBoundSetter + public void setIncludeRegex(@CheckForNull String includeRegex) { + this.includeRegex = Util.fixEmptyAndTrim(includeRegex); + } + + @CheckForNull + public String getExcludeRegex() { + return excludeRegex; + } + + @DataBoundSetter + public void setExcludeRegex(@CheckForNull String excludeRegex) { + this.excludeRegex = Util.fixEmptyAndTrim(excludeRegex); + } + + /** + * When {@code true}, stale tags are logged as "WOULD filter" but not actually excluded. + * Lets you preview the impact of the filter before enabling real filtering. + */ + private boolean dryRun; + + public boolean isDryRun() { + return dryRun; + } + + @DataBoundSetter + public void setDryRun(boolean dryRun) { + this.dryRun = dryRun; + } + + @Override + protected void decorateContext(SCMSourceContext context) { + final Pattern includePattern = includeRegex != null ? Pattern.compile(includeRegex) : null; + final Pattern excludePattern = excludeRegex != null ? Pattern.compile(excludeRegex) : null; + + context.withFilter(new SCMHeadFilter() { + @Override + public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead head) throws IOException { + if (!(request instanceof GitHubSCMSourceRequest) || !(head instanceof GitHubTagSCMHead)) { + return false; + } + String headName = head.getName(); + + if (includePattern != null && !includePattern.matcher(headName).matches()) { + return false; + } + if (excludePattern != null && excludePattern.matcher(headName).matches()) { + return false; + } + + // The timestamp was already resolved once during discovery + // (see GitHubSCMSource's tag retrieval), so no API call is needed here. + long timestamp = ((GitHubTagSCMHead) head).getTimestamp(); + if (timestamp <= 0L) { + // unknown date; don't exclude + return false; + } + + long ageInDays = (System.currentTimeMillis() - timestamp) / (1000L * 60 * 60 * 24); + if (ageInDays >= daysStale) { + if (dryRun) { + request.listener() + .getLogger() + .format( + "%n [stale-dry-run] WOULD filter tag %s. Tag is %d day(s) old" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return false; + } + request.listener() + .getLogger() + .format( + "%n Won't build tag %s. Tag is %d day(s) old" + + " (stale threshold: %d day(s)).%n", + headName, ageInDays, daysStale); + return true; + } + return false; + } + }); + } + + @Symbol("gitHubStaleTagFilter") + @Extension + @Selection + public static class DescriptorImpl extends SCMSourceTraitDescriptor { + + @Override + public String getDisplayName() { + return Messages.StaleTagFilterTrait_DisplayName(); + } + + @Override + public Class getContextClass() { + return GitHubSCMSourceContext.class; + } + + @Override + public Class getSourceClass() { + return GitHubSCMSource.class; + } + + public FormValidation doCheckDaysStale(@QueryParameter int value) { + if (value < 1) { + return FormValidation.error("Days stale must be a positive number."); + } + return FormValidation.ok(); + } + + public FormValidation doCheckIncludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + public FormValidation doCheckExcludeRegex(@QueryParameter String value) { + return validateRegex(value); + } + + private FormValidation validateRegex(String value) { + String trimmed = Util.fixEmptyAndTrim(value); + if (trimmed == null) { + return FormValidation.ok(); + } + try { + java.util.regex.Pattern.compile(trimmed); + return FormValidation.ok(); + } catch (java.util.regex.PatternSyntaxException e) { + return FormValidation.error("Invalid regular expression: " + e.getMessage()); + } + } + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties index cd647028e..0fd72ae25 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties @@ -75,3 +75,7 @@ GitHubSCMNavigator.withinRepository=Within repository GitHubAppCredentials.displayName=GitHub App IgnoreDraftPullRequestFilterTrait.DisplayName=Ignore pull requests marked as drafts +StaleBranchFilterTrait_DisplayName=Filter stale branches +StaleBranchFilterTrait_Tooltip=Ignore branches that have not been committed to for a specified number of days +StaleTagFilterTrait_DisplayName=Filter stale tags +StalePullRequestFilterTrait_DisplayName=Filter stale pull requests diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/config.jelly new file mode 100644 index 000000000..05899bb98 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/config.jelly @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/help.html new file mode 100644 index 000000000..e21356c24 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTrait/help.html @@ -0,0 +1 @@ +Ignore branches that have not been committed to for a specified number of days diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/config.jelly new file mode 100644 index 000000000..44937fbef --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/config.jelly @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/help.html new file mode 100644 index 000000000..b6c956f30 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTrait/help.html @@ -0,0 +1,2 @@ +Ignore pull requests that have had no activity for a specified number of days. +Staleness is measured against the PR's last-updated timestamp, which includes new commits, comments, reviews, and label changes. diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/config.jelly new file mode 100644 index 000000000..848244e17 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/config.jelly @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/help.html new file mode 100644 index 000000000..e2f02c2ca --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTrait/help.html @@ -0,0 +1,2 @@ +Ignore tags that are older than a specified number of days. +For annotated tags, age is measured from the date the tag object was created, not from the date of the commit the tag points to. diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTraitTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTraitTest.java new file mode 100644 index 000000000..26947b0d0 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleBranchFilterTraitTest.java @@ -0,0 +1,292 @@ +package org.jenkinsci.plugins.github_branch_source; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.PrintStream; +import java.util.Collections; +import java.util.Date; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import org.junit.Test; +import org.kohsuke.github.GHBranch; +import org.kohsuke.github.GHCommit; +import org.kohsuke.github.GHRepository; + +public class StaleBranchFilterTraitTest { + + // ── constructor / setters ──────────────────────────────────────────────── + + @Test + public void daysStaleIsStoredAsSupplied() { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + assertEquals(30, trait.getDaysStale()); + } + + @Test + public void daysStaleMinimumIsOne() { + assertEquals(1, new StaleBranchFilterTrait(0).getDaysStale()); + assertEquals(1, new StaleBranchFilterTrait(-5).getDaysStale()); + assertEquals(1, new StaleBranchFilterTrait(1).getDaysStale()); + } + + @Test + public void regexFieldsDefaultToNull() { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void blankRegexIsTreatedAsNull() { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex(" "); + trait.setExcludeRegex(""); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void regexFieldsAreStored() { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex("feature/.*"); + trait.setExcludeRegex("feature/keep-.*"); + assertEquals("feature/.*", trait.getIncludeRegex()); + assertEquals("feature/keep-.*", trait.getExcludeRegex()); + } + + @Test + public void dryRunDefaultsToFalseAndIsStored() { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + assertFalse(trait.isDryRun()); + trait.setDryRun(true); + assertTrue(trait.isDryRun()); + } + + // ── filter builder ─────────────────────────────────────────────────────── + + private SCMHeadFilter buildFilter(StaleBranchFilterTrait trait) { + final SCMHeadFilter[] captured = new SCMHeadFilter[1]; + SCMSourceContext ctx = mock(SCMSourceContext.class); + when(ctx.withFilter(org.mockito.ArgumentMatchers.any())).thenAnswer(inv -> { + captured[0] = inv.getArgument(0); + return ctx; + }); + trait.decorateContext(ctx); + return captured[0]; + } + + private SCMHeadFilter buildFilter(int daysStale) { + return buildFilter(new StaleBranchFilterTrait(daysStale)); + } + + private GitHubSCMSourceRequest mockRequest(String branchName, long ageInDays) throws Exception { + long commitTimeMs = System.currentTimeMillis() - ageInDays * 24 * 60 * 60 * 1000L; + + GHCommit commit = mock(GHCommit.class); + when(commit.getCommitDate()).thenReturn(new Date(commitTimeMs)); + + GHRepository repo = mock(GHRepository.class); + when(repo.getCommit(org.mockito.ArgumentMatchers.anyString())).thenReturn(commit); + + GHBranch branch = mock(GHBranch.class); + when(branch.getName()).thenReturn(branchName); + when(branch.getSHA1()).thenReturn("abc123"); + + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + when(request.getBranches()).thenReturn(Collections.singletonList(branch)); + when(request.getRepository()).thenReturn(repo); + + hudson.model.TaskListener listener = mock(hudson.model.TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + + return request; + } + + // ── basic stale logic ──────────────────────────────────────────────────── + + @Test + public void freshBranchIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("main"); + GitHubSCMSourceRequest request = mockRequest("main", 5); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void staleBranchIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("old-feature"); + GitHubSCMSourceRequest request = mockRequest("old-feature", 31); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void branchExactlyAtThresholdIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("borderline"); + GitHubSCMSourceRequest request = mockRequest("borderline", 30); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void staleBranchInDryRunIsNotExcluded() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setDryRun(true); + SCMHeadFilter filter = buildFilter(trait); + SCMHead head = new BranchSCMHead("old-feature"); + GitHubSCMSourceRequest request = mockRequest("old-feature", 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void defaultBranchIsNeverExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("main"); + + long commitTimeMs = System.currentTimeMillis() - 365L * 24 * 60 * 60 * 1000L; + GHCommit commit = mock(GHCommit.class); + when(commit.getCommitDate()).thenReturn(new Date(commitTimeMs)); + + GHRepository repo = mock(GHRepository.class); + when(repo.getCommit(org.mockito.ArgumentMatchers.anyString())).thenReturn(commit); + when(repo.getDefaultBranch()).thenReturn("main"); + + GHBranch branch = mock(GHBranch.class); + when(branch.getName()).thenReturn("main"); + when(branch.getSHA1()).thenReturn("abc123"); + + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + when(request.getBranches()).thenReturn(Collections.singletonList(branch)); + when(request.getRepository()).thenReturn(repo); + + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void protectedBranchIsNeverExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("release"); + + long commitTimeMs = System.currentTimeMillis() - 365L * 24 * 60 * 60 * 1000L; + GHCommit commit = mock(GHCommit.class); + when(commit.getCommitDate()).thenReturn(new Date(commitTimeMs)); + + GHRepository repo = mock(GHRepository.class); + when(repo.getCommit(org.mockito.ArgumentMatchers.anyString())).thenReturn(commit); + when(repo.getDefaultBranch()).thenReturn("main"); + + GHBranch branch = mock(GHBranch.class); + when(branch.getName()).thenReturn("release"); + when(branch.getSHA1()).thenReturn("abc123"); + when(branch.isProtected()).thenReturn(true); + + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + when(request.getBranches()).thenReturn(Collections.singletonList(branch)); + when(request.getRepository()).thenReturn(repo); + + hudson.model.TaskListener listener = mock(hudson.model.TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void nonBranchHeadIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead prHead = mock(SCMHead.class); + SCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + assertFalse(filter.isExcluded(request, prHead)); + } + + @Test + public void nonGitHubRequestIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new BranchSCMHead("main"); + SCMSourceRequest request = mock(SCMSourceRequest.class); + assertFalse(filter.isExcluded(request, head)); + } + + // ── includeRegex ───────────────────────────────────────────────────────── + + @Test + public void staleBranchMatchingIncludeRegexIsExcluded() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex("feature/.*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = new BranchSCMHead("feature/old"); + GitHubSCMSourceRequest request = mockRequest("feature/old", 60); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void staleBranchNotMatchingIncludeRegexIsNotExcluded() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex("feature/.*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = new BranchSCMHead("bugfix/old"); + GitHubSCMSourceRequest request = mockRequest("bugfix/old", 60); + assertFalse(filter.isExcluded(request, head)); + } + + // ── excludeRegex ───────────────────────────────────────────────────────── + + @Test + public void staleBranchMatchingExcludeRegexIsNotExcluded() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setExcludeRegex("release/.*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = new BranchSCMHead("release/1.0"); + GitHubSCMSourceRequest request = mockRequest("release/1.0", 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void staleBranchNotMatchingExcludeRegexIsExcluded() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setExcludeRegex("release/.*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = new BranchSCMHead("feature/old"); + GitHubSCMSourceRequest request = mockRequest("feature/old", 60); + assertTrue(filter.isExcluded(request, head)); + } + + // ── includeRegex + excludeRegex together ───────────────────────────────── + + @Test + public void excludeRegexTakesPrecedenceOverIncludeRegex() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex("feature/.*"); + trait.setExcludeRegex("feature/keep-.*"); + SCMHeadFilter filter = buildFilter(trait); + + // Matches include but also matches exclude — should not be excluded + SCMHead head = new BranchSCMHead("feature/keep-this"); + GitHubSCMSourceRequest request = mockRequest("feature/keep-this", 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void matchesIncludeButNotExcludeIsExcludedWhenStale() throws Exception { + StaleBranchFilterTrait trait = new StaleBranchFilterTrait(30); + trait.setIncludeRegex("feature/.*"); + trait.setExcludeRegex("feature/keep-.*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = new BranchSCMHead("feature/old"); + GitHubSCMSourceRequest request = mockRequest("feature/old", 60); + assertTrue(filter.isExcluded(request, head)); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTraitTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTraitTest.java new file mode 100644 index 000000000..0734b0be8 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/StalePullRequestFilterTraitTest.java @@ -0,0 +1,259 @@ +package org.jenkinsci.plugins.github_branch_source; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.PrintStream; +import java.util.Collections; +import java.util.Date; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import org.junit.Test; +import org.kohsuke.github.GHPullRequest; + +public class StalePullRequestFilterTraitTest { + + // ── constructor / setters ──────────────────────────────────────────────── + + @Test + public void daysStaleIsStoredAsSupplied() { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + assertEquals(30, trait.getDaysStale()); + } + + @Test + public void daysStaleMinimumIsOne() { + assertEquals(1, new StalePullRequestFilterTrait(0).getDaysStale()); + assertEquals(1, new StalePullRequestFilterTrait(-5).getDaysStale()); + assertEquals(1, new StalePullRequestFilterTrait(1).getDaysStale()); + } + + @Test + public void regexFieldsDefaultToNull() { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void blankRegexIsTreatedAsNull() { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex(" "); + trait.setExcludeRegex(""); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void regexFieldsAreStored() { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex("PR-[0-9]+"); + trait.setExcludeRegex("PR-1"); + assertEquals("PR-[0-9]+", trait.getIncludeRegex()); + assertEquals("PR-1", trait.getExcludeRegex()); + } + + @Test + public void dryRunDefaultsToFalseAndIsStored() { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + assertFalse(trait.isDryRun()); + trait.setDryRun(true); + assertTrue(trait.isDryRun()); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private SCMHeadFilter buildFilter(StalePullRequestFilterTrait trait) { + final SCMHeadFilter[] captured = new SCMHeadFilter[1]; + SCMSourceContext ctx = mock(SCMSourceContext.class); + when(ctx.withFilter(org.mockito.ArgumentMatchers.any())).thenAnswer(inv -> { + captured[0] = inv.getArgument(0); + return ctx; + }); + trait.decorateContext(ctx); + return captured[0]; + } + + private SCMHeadFilter buildFilter(int daysStale) { + return buildFilter(new StalePullRequestFilterTrait(daysStale)); + } + + private GitHubSCMSourceRequest mockRequest(int prNumber, long ageInDays) throws Exception { + long updatedTimeMs = System.currentTimeMillis() - ageInDays * 24 * 60 * 60 * 1000L; + + GHPullRequest pr = mock(GHPullRequest.class); + when(pr.getNumber()).thenReturn(prNumber); + when(pr.getUpdatedAt()).thenReturn(new Date(updatedTimeMs)); + + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + when(request.getPullRequests()).thenReturn(Collections.singletonList(pr)); + + hudson.model.TaskListener listener = mock(hudson.model.TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + + return request; + } + + private GitHubSCMSourceRequest mockRequestWithNullUpdatedAt(int prNumber) throws Exception { + GHPullRequest pr = mock(GHPullRequest.class); + when(pr.getNumber()).thenReturn(prNumber); + when(pr.getUpdatedAt()).thenReturn(null); + + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + when(request.getPullRequests()).thenReturn(Collections.singletonList(pr)); + + hudson.model.TaskListener listener = mock(hudson.model.TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + + return request; + } + + private PullRequestSCMHead mockPRHead(int number) { + PullRequestSCMHead head = mock(PullRequestSCMHead.class); + when(head.getName()).thenReturn("PR-" + number); + when(head.getNumber()).thenReturn(number); + return head; + } + + // ── basic stale logic ──────────────────────────────────────────────────── + + @Test + public void freshPullRequestIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 5); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void stalePullRequestIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 31); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void pullRequestExactlyAtThresholdIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 30); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void stalePullRequestInDryRunIsNotExcluded() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setDryRun(true); + SCMHeadFilter filter = buildFilter(trait); + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void nullUpdatedAtIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequestWithNullUpdatedAt(42); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void nonPullRequestHeadIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead branchHead = new BranchSCMHead("main"); + SCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + assertFalse(filter.isExcluded(request, branchHead)); + } + + @Test + public void nonGitHubRequestIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + PullRequestSCMHead head = mockPRHead(42); + SCMSourceRequest request = mock(SCMSourceRequest.class); + assertFalse(filter.isExcluded(request, head)); + } + + // ── includeRegex ───────────────────────────────────────────────────────── + + @Test + public void stalePRMatchingIncludeRegexIsExcluded() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex("PR-[0-9]+"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 60); + assertTrue(filter.isExcluded(request, head)); + } + + @Test + public void stalePRNotMatchingIncludeRegexIsNotExcluded() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex("PR-1"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 60); + assertFalse(filter.isExcluded(request, head)); + } + + // ── excludeRegex ───────────────────────────────────────────────────────── + + @Test + public void stalePRMatchingExcludeRegexIsNotExcluded() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setExcludeRegex("PR-1"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(1); + GitHubSCMSourceRequest request = mockRequest(1, 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void stalePRNotMatchingExcludeRegexIsExcluded() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setExcludeRegex("PR-1"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 60); + assertTrue(filter.isExcluded(request, head)); + } + + // ── includeRegex + excludeRegex together ───────────────────────────────── + + @Test + public void excludeRegexTakesPrecedenceOverIncludeRegex() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex("PR-[0-9]+"); + trait.setExcludeRegex("PR-1"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(1); + GitHubSCMSourceRequest request = mockRequest(1, 60); + assertFalse(filter.isExcluded(request, head)); + } + + @Test + public void matchesIncludeButNotExcludeIsExcludedWhenStale() throws Exception { + StalePullRequestFilterTrait trait = new StalePullRequestFilterTrait(30); + trait.setIncludeRegex("PR-[0-9]+"); + trait.setExcludeRegex("PR-1"); + SCMHeadFilter filter = buildFilter(trait); + + PullRequestSCMHead head = mockPRHead(42); + GitHubSCMSourceRequest request = mockRequest(42, 60); + assertTrue(filter.isExcluded(request, head)); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTraitTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTraitTest.java new file mode 100644 index 000000000..5303374a9 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/StaleTagFilterTraitTest.java @@ -0,0 +1,224 @@ +package org.jenkinsci.plugins.github_branch_source; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.PrintStream; +import jenkins.scm.api.SCMHead; +import jenkins.scm.api.trait.SCMHeadFilter; +import jenkins.scm.api.trait.SCMSourceContext; +import jenkins.scm.api.trait.SCMSourceRequest; +import org.junit.Test; + +public class StaleTagFilterTraitTest { + + // ── constructor / setters ──────────────────────────────────────────────── + + @Test + public void daysStaleIsStoredAsSupplied() { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + assertEquals(30, trait.getDaysStale()); + } + + @Test + public void daysStaleMinimumIsOne() { + assertEquals(1, new StaleTagFilterTrait(0).getDaysStale()); + assertEquals(1, new StaleTagFilterTrait(-5).getDaysStale()); + assertEquals(1, new StaleTagFilterTrait(1).getDaysStale()); + } + + @Test + public void regexFieldsDefaultToNull() { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void blankRegexIsTreatedAsNull() { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex(" "); + trait.setExcludeRegex(""); + assertNull(trait.getIncludeRegex()); + assertNull(trait.getExcludeRegex()); + } + + @Test + public void regexFieldsAreStored() { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex("v[0-9]+\\..*"); + trait.setExcludeRegex("v.*-lts"); + assertEquals("v[0-9]+\\..*", trait.getIncludeRegex()); + assertEquals("v.*-lts", trait.getExcludeRegex()); + } + + @Test + public void dryRunDefaultsToFalseAndIsStored() { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + assertFalse(trait.isDryRun()); + trait.setDryRun(true); + assertTrue(trait.isDryRun()); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private SCMHeadFilter buildFilter(StaleTagFilterTrait trait) { + final SCMHeadFilter[] captured = new SCMHeadFilter[1]; + SCMSourceContext ctx = mock(SCMSourceContext.class); + when(ctx.withFilter(org.mockito.ArgumentMatchers.any())).thenAnswer(inv -> { + captured[0] = inv.getArgument(0); + return ctx; + }); + trait.decorateContext(ctx); + return captured[0]; + } + + private SCMHeadFilter buildFilter(int daysStale) { + return buildFilter(new StaleTagFilterTrait(daysStale)); + } + + /** The tag's timestamp is carried on the head itself (set at discovery time). */ + private GitHubTagSCMHead tagHead(String tagName, long ageInDays) { + long tagTimeMs = System.currentTimeMillis() - ageInDays * 24 * 60 * 60 * 1000L; + return new GitHubTagSCMHead(tagName, tagTimeMs); + } + + private GitHubSCMSourceRequest mockRequest() throws Exception { + GitHubSCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + hudson.model.TaskListener listener = mock(hudson.model.TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + return request; + } + + // ── basic stale logic ──────────────────────────────────────────────────── + + @Test + public void freshTagIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = tagHead("v1.0.0", 5); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void staleTagIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = tagHead("v0.1.0", 31); + assertTrue(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void tagExactlyAtThresholdIsExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = tagHead("v0.2.0", 30); + assertTrue(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void staleTagInDryRunIsNotExcluded() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setDryRun(true); + SCMHeadFilter filter = buildFilter(trait); + SCMHead head = tagHead("v0.1.0", 60); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void tagWithUnknownTimestampIsNotExcluded() throws Exception { + // discovery writes a 0L sentinel when it couldn't resolve a date + SCMHeadFilter filter = buildFilter(30); + SCMHead head = new GitHubTagSCMHead("v0.3.0", 0L); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + // ── non-tag heads ──────────────────────────────────────────────────────── + + @Test + public void nonTagHeadIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead branchHead = new BranchSCMHead("main"); + SCMSourceRequest request = mock(GitHubSCMSourceRequest.class); + assertFalse(filter.isExcluded(request, branchHead)); + } + + @Test + public void nonGitHubRequestIsNotExcluded() throws Exception { + SCMHeadFilter filter = buildFilter(30); + SCMHead head = tagHead("v1.0.0", 60); + SCMSourceRequest request = mock(SCMSourceRequest.class); + assertFalse(filter.isExcluded(request, head)); + } + + // ── includeRegex ───────────────────────────────────────────────────────── + + @Test + public void staleTagMatchingIncludeRegexIsExcluded() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex("v[0-9]+\\..*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = tagHead("v1.2.3", 60); + assertTrue(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void staleTagNotMatchingIncludeRegexIsNotExcluded() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex("v[0-9]+\\..*"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = tagHead("nightly-20240101", 60); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + // ── excludeRegex ───────────────────────────────────────────────────────── + + @Test + public void staleTagMatchingExcludeRegexIsNotExcluded() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setExcludeRegex(".*-lts"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = tagHead("v2.0-lts", 365); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void staleTagNotMatchingExcludeRegexIsExcluded() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setExcludeRegex(".*-lts"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = tagHead("v1.9.0", 60); + assertTrue(filter.isExcluded(mockRequest(), head)); + } + + // ── includeRegex + excludeRegex together ───────────────────────────────── + + @Test + public void excludeRegexTakesPrecedenceOverIncludeRegex() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex("v[0-9]+\\..*"); + trait.setExcludeRegex(".*-lts"); + SCMHeadFilter filter = buildFilter(trait); + + // Matches include but also matches exclude — should not be excluded + SCMHead head = tagHead("v2.0-lts", 365); + assertFalse(filter.isExcluded(mockRequest(), head)); + } + + @Test + public void matchesIncludeButNotExcludeIsExcludedWhenStale() throws Exception { + StaleTagFilterTrait trait = new StaleTagFilterTrait(30); + trait.setIncludeRegex("v[0-9]+\\..*"); + trait.setExcludeRegex(".*-lts"); + SCMHeadFilter filter = buildFilter(trait); + + SCMHead head = tagHead("v1.9.0", 60); + assertTrue(filter.isExcluded(mockRequest(), head)); + } +}