result = new ArrayList<>();
+ for (String entry : value.split(",")) {
+ String trimmed = entry.trim();
+ if (!trimmed.isEmpty()) {
+ result.add(trimmed);
+ }
+ }
+ return result.isEmpty() ? null : Collections.unmodifiableList(result);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean checkTrusted(@NonNull GitHubSCMSourceRequest request, @NonNull PullRequestSCMHead head)
+ throws IOException, InterruptedException {
+ if (autoApprovalUsers != null) {
+ for (String user : autoApprovalUsers) {
+ // GitHub logins are case-insensitive.
+ if (user.equalsIgnoreCase(head.getSourceOwner())) {
+ return true;
+ }
+ }
+ }
+ if (autoApprovalLabels != null && !autoApprovalLabels.isEmpty()) {
+ for (GHPullRequest pr : request.getPullRequests()) {
+ if (pr.getNumber() != head.getNumber()) {
+ continue;
+ }
+ for (GHLabel label : pr.getLabels()) {
+ if (autoApprovalLabels.contains(label.getName())) {
+ return true;
+ }
+ }
+ break;
+ }
+ }
+ return false;
+ }
+
+ /** Our descriptor. */
+ @Symbol("gitHubTrustExternalApproval")
+ @Extension
+ public static class DescriptorImpl extends SCMHeadAuthorityDescriptor {
+
+ /** {@inheritDoc} */
+ @Override
+ public String getDisplayName() {
+ return Messages.ForkPullRequestDiscoveryTrait_externalApprovalDisplayName();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isApplicableToOrigin(@NonNull Class extends SCMHeadOrigin> originClass) {
+ return SCMHeadOrigin.Fork.class.isAssignableFrom(originClass);
+ }
+
+ @Restricted(NoExternalUse.class)
+ @SuppressWarnings("unused") // stapler
+ @POST
+ public FormValidation doCheckAutoApprovalUsers(
+ @CheckForNull @AncestorInPath Item context, @QueryParameter String value) {
+ // Only someone who can configure the job, or an admin when there is no job in the path.
+ if (context == null) {
+ Jenkins.get().checkPermission(Jenkins.ADMINISTER);
+ } else {
+ context.checkPermission(Item.CONFIGURE);
+ }
+ if (value == null || value.isBlank()) {
+ return FormValidation.ok();
+ }
+ for (String entry : value.split(",")) {
+ String trimmed = entry.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ if (trimmed.contains(" ")) {
+ return FormValidation.warning("GitHub logins should not contain spaces: '" + trimmed + "'");
+ }
+ }
+ return FormValidation.ok();
+ }
+ }
+ }
}
diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction.java
new file mode 100644
index 000000000..b59ebfc61
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction.java
@@ -0,0 +1,419 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2026 Olivier Lamy
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.jenkinsci.plugins.github_branch_source;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import hudson.Extension;
+import hudson.XmlFile;
+import hudson.model.Action;
+import hudson.model.Cause;
+import hudson.model.CauseAction;
+import hudson.model.Item;
+import hudson.model.Job;
+import hudson.model.Run;
+import hudson.model.TaskListener;
+import hudson.model.listeners.ItemListener;
+import hudson.model.listeners.RunListener;
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jenkins.branch.MultiBranchProject;
+import jenkins.model.Jenkins;
+import jenkins.model.ParameterizedJobMixIn;
+import jenkins.model.TransientActionFactory;
+import org.kohsuke.stapler.HttpRedirect;
+import org.kohsuke.stapler.HttpResponse;
+import org.kohsuke.stapler.StaplerRequest2;
+import org.kohsuke.stapler.verb.POST;
+
+/**
+ * Shown on a branch job while its fork pull request waits for external approval, with the buttons
+ * and endpoints an administrator uses to approve or take back that approval.
+ *
+ * What actually holds the pull request back is the job's own disabled flag. Until someone
+ * approves, the job stays disabled, so neither branch indexing nor a person clicking Build can start
+ * it.
+ */
+public class PendingApprovalAction implements Action {
+
+ private static final Logger LOGGER = Logger.getLogger(PendingApprovalAction.class.getName());
+
+ /** Marker stored as the approver when a PR was approved automatically. */
+ private static final String AUTO_APPROVAL = "auto-approval";
+
+ private final transient Job, ?> owner;
+ private final ApprovalState state;
+ private final int prNumber;
+ private final String prAuthor;
+ private final String currentPullHash;
+ private final boolean requireApprovalForNewCommits;
+
+ PendingApprovalAction(
+ Job, ?> owner,
+ ApprovalState state,
+ int prNumber,
+ String prAuthor,
+ String currentPullHash,
+ boolean requireApprovalForNewCommits) {
+ this.owner = owner;
+ this.state = state;
+ this.prNumber = prNumber;
+ this.prAuthor = prAuthor;
+ this.currentPullHash = currentPullHash;
+ this.requireApprovalForNewCommits = requireApprovalForNewCommits;
+ }
+
+ @Override
+ public String getIconFileName() {
+ if (state == ApprovalState.PENDING) {
+ return "symbol-warning plugin-ionicons-api";
+ }
+ return null;
+ }
+
+ @Override
+ public String getDisplayName() {
+ if (state == ApprovalState.PENDING) {
+ return Messages.PendingApprovalAction_displayName();
+ }
+ return Messages.PendingApprovalAction_approved();
+ }
+
+ @Override
+ public String getUrlName() {
+ return "pendingApproval";
+ }
+
+ public ApprovalState getState() {
+ return state;
+ }
+
+ public int getPrNumber() {
+ return prNumber;
+ }
+
+ public String getPrAuthor() {
+ return prAuthor;
+ }
+
+ public String getCurrentPullHash() {
+ return currentPullHash;
+ }
+
+ public boolean isRequireApprovalForNewCommits() {
+ return requireApprovalForNewCommits;
+ }
+
+ public Job, ?> getOwner() {
+ return owner;
+ }
+
+ /** Approves the pull request: enables the job and starts a build. */
+ @POST
+ public HttpResponse doApprove(StaplerRequest2 req) {
+ owner.checkPermission(Item.CONFIGURE);
+ try {
+ String approvedBy = Jenkins.get().getAuthentication2().getName();
+ ApprovalData data = ApprovalData.load(owner);
+ data.state = ApprovalState.APPROVED;
+ data.approvedBy = approvedBy;
+ data.approvedAt = System.currentTimeMillis();
+ data.approvedPullHash = currentPullHash;
+ data.save(owner);
+ setDisabled(owner, false);
+ if (ParameterizedJobMixIn.scheduleBuild2(owner, 0, new CauseAction(new ExternalApprovalCause())) == null) {
+ LOGGER.log(Level.WARNING, "Failed to schedule build for {0}", owner.getFullName());
+ }
+ LOGGER.log(Level.INFO, "PR #{0} in {1} approved by {2}", new Object[] {
+ prNumber, owner.getFullName(), approvedBy
+ });
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to approve PR #" + prNumber, e);
+ }
+ return new HttpRedirect("..");
+ }
+
+ /** Takes the approval back and disables the job again. */
+ @POST
+ public HttpResponse doReject(StaplerRequest2 req) {
+ owner.checkPermission(Item.CONFIGURE);
+ try {
+ ApprovalData data = ApprovalData.load(owner);
+ data.reset();
+ data.save(owner);
+ setDisabled(owner, true);
+ LOGGER.log(Level.INFO, "PR #{0} in {1} rejected by {2}", new Object[] {
+ prNumber,
+ owner.getFullName(),
+ Jenkins.get().getAuthentication2().getName()
+ });
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to reject PR #" + prNumber, e);
+ }
+ return new HttpRedirect("..");
+ }
+
+ /** Mirrors the approval onto the job. Anything short of an approval leaves it disabled. */
+ private static void applyApprovalState(Job, ?> job, ApprovalState state) {
+ setDisabled(job, state != ApprovalState.APPROVED);
+ }
+
+ private static void setDisabled(Job, ?> job, boolean disabled) {
+ if (!(job instanceof ParameterizedJobMixIn.ParameterizedJob)) {
+ LOGGER.log(Level.WARNING, "Cannot change the disabled state of {0}", job.getFullName());
+ return;
+ }
+ ParameterizedJobMixIn.ParameterizedJob, ?> project = (ParameterizedJobMixIn.ParameterizedJob, ?>) job;
+ if (project.isDisabled() == disabled) {
+ return;
+ }
+ try {
+ // Disabling also cancels anything this job already has queued.
+ project.makeDisabled(disabled);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Cannot change the disabled state of " + job.getFullName(), e);
+ }
+ }
+
+ /** Approval state of an external pull request. */
+ public enum ApprovalState {
+ PENDING,
+ APPROVED
+ }
+
+ /** Marks a build as having been triggered by an external approval. */
+ public static class ExternalApprovalCause extends Cause {
+ @Override
+ public String getShortDescription() {
+ return "External approval granted";
+ }
+ }
+
+ /** The approval state, saved alongside the job in its directory. */
+ static class ApprovalData implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ ApprovalState state = ApprovalState.PENDING;
+
+ @Nullable
+ String approvedBy;
+
+ long approvedAt;
+
+ @Nullable
+ String approvedPullHash;
+
+ /** Drops any approval, sending the pull request back to pending. */
+ void reset() {
+ state = ApprovalState.PENDING;
+ approvedBy = null;
+ approvedAt = 0;
+ approvedPullHash = null;
+ }
+
+ static XmlFile getConfigFile(Job, ?> job) {
+ return new XmlFile(new File(job.getRootDir(), "pending-approval.xml"));
+ }
+
+ static ApprovalData load(Job, ?> job) {
+ XmlFile file = getConfigFile(job);
+ if (file.exists()) {
+ try {
+ return (ApprovalData) file.read();
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to load approval data for " + job.getFullName(), e);
+ }
+ }
+ return new ApprovalData();
+ }
+
+ void save(Job, ?> job) throws IOException {
+ getConfigFile(job).write(this);
+ }
+
+ static boolean exists(Job, ?> job) {
+ return getConfigFile(job).exists();
+ }
+
+ static void delete(Job, ?> job) {
+ XmlFile file = getConfigFile(job);
+ if (file.exists()) {
+ if (!file.getFile().delete()) {
+ LOGGER.log(Level.WARNING, "Failed to delete approval data for {0}", job.getFullName());
+ }
+ }
+ }
+ }
+
+ /** Attaches a {@link PendingApprovalAction} to any branch job that needs external approval. */
+ @Extension
+ public static class ActionFactory extends TransientActionFactory {
+
+ @Override
+ public Class type() {
+ return Job.class;
+ }
+
+ @NonNull
+ @Override
+ public Class extends Action> actionType() {
+ return PendingApprovalAction.class;
+ }
+
+ @NonNull
+ @Override
+ public Collection extends Action> createFor(@NonNull Job target) {
+ ExternalApprovalInfo info = ExternalApprovalHelper.getApprovalInfo(target);
+ if (info == null) {
+ return Collections.emptyList();
+ }
+ ApprovalData data = resolveApprovalData(target, info);
+ return Collections.singletonList(new PendingApprovalAction(
+ target,
+ data.state,
+ info.prNumber,
+ info.prAuthor,
+ info.currentPullHash,
+ info.requireApprovalForNewCommits));
+ }
+ }
+
+ /**
+ * Puts a newly discovered fork pull request on hold, and catches the jobs that were already
+ * there when the trust policy got switched on.
+ */
+ @Extension
+ public static class ApprovalItemListener extends ItemListener {
+
+ @Override
+ public void onCreated(Item item) {
+ if (item instanceof Job) {
+ refresh((Job, ?>) item);
+ }
+ }
+
+ @Override
+ public void onUpdated(Item item) {
+ if (item instanceof MultiBranchProject) {
+ for (Item child : ((MultiBranchProject, ?>) item).getItems()) {
+ if (child instanceof Job) {
+ refresh((Job, ?>) child);
+ }
+ }
+ }
+ }
+
+ private static void refresh(Job, ?> job) {
+ ExternalApprovalInfo info = ExternalApprovalHelper.getApprovalInfo(job);
+ if (info != null) {
+ applyApprovalState(job, resolveApprovalData(job, info).state);
+ }
+ }
+ }
+
+ /**
+ * Spends the approval once the build it was granted for has started: the job goes back to
+ * disabled, so the next commit needs a fresh approval. Only does anything when the trust policy
+ * asks for approval on new commits.
+ */
+ @Extension
+ public static class ApprovalSpender extends RunListener> {
+
+ @Override
+ public void onStarted(Run, ?> run, TaskListener listener) {
+ Job, ?> job = run.getParent();
+ ExternalApprovalInfo info = ExternalApprovalHelper.getApprovalInfo(job);
+ if (info == null || !info.requireApprovalForNewCommits) {
+ return;
+ }
+ if (ExternalApprovalHelper.isAutoApprovedUser(info)) {
+ // Authors on the auto-approval list never have to ask again.
+ return;
+ }
+ ApprovalData data = ApprovalData.load(job);
+ if (data.state != ApprovalState.APPROVED) {
+ return;
+ }
+ data.reset();
+ try {
+ data.save(job);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to reset the approval of " + job.getFullName(), e);
+ return;
+ }
+ setDisabled(job, true);
+ listener.getLogger()
+ .println("Approval spent: the next build of PR #" + info.prNumber + " needs a new approval.");
+ }
+ }
+
+ /**
+ * Loads the approval record, writing it the first time we see a pull request and looking at it
+ * again once the approved commit has moved on. This is the only writer of the approval state,
+ * and it can cost a GitHub call to read labels, so it does nothing at all in between. Whatever
+ * it changes is saved and mirrored onto the job.
+ */
+ private static ApprovalData resolveApprovalData(Job, ?> job, ExternalApprovalInfo info) {
+ ApprovalData data = ApprovalData.load(job);
+ boolean changed = false;
+ if (!ApprovalData.exists(job)) {
+ // First time we see this PR: approve it straight away if it matches the users or labels.
+ if (ExternalApprovalHelper.evaluateAutoApproval(info)) {
+ data.state = ApprovalState.APPROVED;
+ data.approvedBy = AUTO_APPROVAL;
+ data.approvedPullHash = info.currentPullHash;
+ } else {
+ data.state = ApprovalState.PENDING;
+ }
+ changed = true;
+ } else if (info.requireApprovalForNewCommits
+ && data.state == ApprovalState.APPROVED
+ && data.approvedPullHash != null
+ && !data.approvedPullHash.equals(info.currentPullHash)) {
+ // Someone pushed after the approval. It only stays approved if it still auto-approves,
+ // otherwise it goes back to waiting for a person.
+ if (ExternalApprovalHelper.evaluateAutoApproval(info)) {
+ data.approvedBy = AUTO_APPROVAL;
+ data.approvedPullHash = info.currentPullHash;
+ } else {
+ data.reset();
+ }
+ changed = true;
+ }
+ if (changed) {
+ try {
+ data.save(job);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to persist approval data for " + job.getFullName(), e);
+ }
+ applyApprovalState(job, data.state);
+ }
+ return data;
+ }
+}
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.jelly
new file mode 100644
index 000000000..5a912be8f
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.jelly
@@ -0,0 +1,14 @@
+
+
+
+ ${%blurb}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.properties
new file mode 100644
index 000000000..a7f605be1
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait/TrustExternalApproval/config.properties
@@ -0,0 +1,3 @@
+blurb=Fork pull requests will be created as disabled jobs requiring explicit approval before building.
+Auto-approval\ labels=Auto-approval labels (comma-separated). PRs with any of these GitHub labels will be automatically trusted.
+Auto-approval\ users=Auto-approval users (comma-separated GitHub logins). PRs authored by any of these users will be automatically trusted.
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..1a8fd0a86 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
@@ -10,6 +10,11 @@ ForkPullRequestDiscoveryTrait.headAndMerge=Both the current pull request revisio
ForkPullRequestDiscoveryTrait.headOnly=The current pull request revision
ForkPullRequestDiscoveryTrait.mergeOnly=Merging the pull request with the current target branch revision
ForkPullRequestDiscoveryTrait.nobodyDisplayName=Nobody
+ForkPullRequestDiscoveryTrait.externalApprovalDisplayName=External approval required
+
+PendingApprovalAction.displayName=Pending Approval
+PendingApprovalAction.approved=Approved
+PendingApprovalAction.rejected=Rejected
GitHubBranchFilter.DisplayName=GitHub Branch Jobs Only
GitHubBuildStatusNotification.CommitStatus.Good=This commit looks good
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/index.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/index.jelly
new file mode 100644
index 000000000..397e93fa1
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/index.jelly
@@ -0,0 +1,58 @@
+
+
+
+
+
+ External Approval Required
+
+
+ | Pull Request |
+ #${it.prNumber} |
+
+
+ | Author |
+ ${it.prAuthor} |
+
+
+ | Status |
+
+
+
+ Pending Approval
+
+
+ Approved
+
+
+ |
+
+
+
+ | Commit |
+ ${it.currentPullHash} |
+
+
+
+
+ | Mode |
+ Approval required for each new commit |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/summary.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/summary.jelly
new file mode 100644
index 000000000..4371c1b52
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/PendingApprovalAction/summary.jelly
@@ -0,0 +1,14 @@
+
+
+
+
+
+ This fork pull request requires external approval before it can build.
+
+ PR #${it.prNumber} from ${it.prAuthor}
+
+ (commit ${it.currentPullHash.length() > 7 ? it.currentPullHash.substring(0, 7) : it.currentPullHash})
+
+
+
+
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait2Test.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait2Test.java
index c5c3b49c5..06ae099ec 100644
--- a/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait2Test.java
+++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTrait2Test.java
@@ -52,6 +52,7 @@ public void configRoundtrip() throws Exception {
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustEveryone(), false);
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustContributors(), false);
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustPermission(), false);
+ assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustExternalApproval(), false);
}
@Test
@@ -62,6 +63,7 @@ public void configRoundtripWithRawUrl() throws Exception {
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustEveryone(), true);
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustContributors(), true);
assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustPermission(), true);
+ assertRoundTrip(p, new ForkPullRequestDiscoveryTrait.TrustExternalApproval(), true);
}
private void assertRoundTrip(
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitDescriptorTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitDescriptorTest.java
new file mode 100644
index 000000000..2996e6777
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitDescriptorTest.java
@@ -0,0 +1,81 @@
+package org.jenkinsci.plugins.github_branch_source;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import java.net.URL;
+import java.util.Arrays;
+import jenkins.model.Jenkins;
+import org.htmlunit.FailingHttpStatusCodeException;
+import org.htmlunit.HttpMethod;
+import org.htmlunit.Page;
+import org.htmlunit.WebRequest;
+import org.htmlunit.util.NameValuePair;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.MockAuthorizationStrategy;
+import org.kohsuke.stapler.StaplerRequest2;
+
+/**
+ * Checks that the auto-approval users form validation only answers POST requests from someone
+ * allowed to configure it.
+ */
+public class ForkPullRequestDiscoveryTraitDescriptorTest {
+
+ private static final String CHECK_URL =
+ "descriptorByName/org.jenkinsci.plugins.github_branch_source.ForkPullRequestDiscoveryTrait$TrustExternalApproval/checkAutoApprovalUsers?value=alice";
+
+ @Rule
+ public final JenkinsRule j = new JenkinsRule();
+
+ @Before
+ public void setUp() {
+ j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
+ MockAuthorizationStrategy auth = new MockAuthorizationStrategy();
+ auth.grant(Jenkins.ADMINISTER).everywhere().to("alice");
+ auth.grant(Jenkins.READ).everywhere().toEveryone();
+ j.jenkins.setAuthorizationStrategy(auth);
+ }
+
+ @Test
+ public void getIsNotAnswered() throws Exception {
+ try {
+ Page page = request(HttpMethod.GET, "alice");
+ fail("GET should not reach the check method, got "
+ + page.getWebResponse().getStatusCode());
+ } catch (FailingHttpStatusCodeException e) {
+ assertEquals(404, e.getStatusCode());
+ }
+ }
+
+ @Test
+ public void postAsAdminIsAnswered() throws Exception {
+ Page page = request(HttpMethod.POST, "alice");
+ assertEquals(200, page.getWebResponse().getStatusCode());
+ }
+
+ @Test
+ public void postAsReadOnlyIsRejected() throws Exception {
+ // "bob" has only Overall/Read, and there is no job in the path to fall back on.
+ try {
+ request(HttpMethod.POST, "bob");
+ fail("Should not be able to do that");
+ } catch (FailingHttpStatusCodeException e) {
+ assertEquals(403, e.getStatusCode());
+ }
+ }
+
+ private Page request(HttpMethod method, String userName) throws Exception {
+ JenkinsRule.WebClient client = j.createWebClient().login(userName);
+ client.getOptions().setThrowExceptionOnFailingStatusCode(true);
+ WebRequest request = new WebRequest(new URL(client.getContextPath() + CHECK_URL), method);
+ request.setAdditionalHeader("Accept", client.getBrowserVersion().getHtmlAcceptHeader());
+ if (method == HttpMethod.POST) {
+ request.setRequestParameters(Arrays.asList(new NameValuePair(
+ hudson.Functions.getCrumbRequestField(), hudson.Functions.getCrumb((StaplerRequest2) null))));
+ }
+ return client.getPage(request);
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitTest.java
index dbf93e8ee..24b8c8c1d 100644
--- a/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitTest.java
+++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/ForkPullRequestDiscoveryTraitTest.java
@@ -100,4 +100,107 @@ public void given__nonDefaultTrust__when__appliedToContext__then__authoritiesCor
assertThat(ctx.forkPRStrategies(), Matchers.is(EnumSet.allOf(ChangeRequestCheckoutStrategy.class)));
assertThat(ctx.authorities(), hasItem(instanceOf(ForkPullRequestDiscoveryTrait.TrustEveryone.class)));
}
+
+ @Test
+ public void given__externalApproval__when__appliedToContext__then__authoritiesCorrect() throws Exception {
+ GitHubSCMSourceContext ctx = new GitHubSCMSourceContext(null, SCMHeadObserver.none());
+ assumeThat(ctx.wantBranches(), is(false));
+ assumeThat(ctx.wantPRs(), is(false));
+ assumeThat(ctx.prefilters(), is(Collections.emptyList()));
+ assumeThat(ctx.filters(), is(Collections.emptyList()));
+ assumeThat(
+ ctx.authorities(), not(hasItem(instanceOf(ForkPullRequestDiscoveryTrait.TrustExternalApproval.class))));
+ ForkPullRequestDiscoveryTrait instance = new ForkPullRequestDiscoveryTrait(
+ EnumSet.allOf(ChangeRequestCheckoutStrategy.class),
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval());
+ instance.decorateContext(ctx);
+ assertThat(ctx.wantBranches(), is(false));
+ assertThat(ctx.wantPRs(), is(true));
+ assertThat(ctx.prefilters(), is(Collections.emptyList()));
+ assertThat(ctx.filters(), is(Collections.emptyList()));
+ assertThat(ctx.forkPRStrategies(), Matchers.is(EnumSet.allOf(ChangeRequestCheckoutStrategy.class)));
+ assertThat(ctx.authorities(), hasItem(instanceOf(ForkPullRequestDiscoveryTrait.TrustExternalApproval.class)));
+ }
+
+ @Test
+ public void given__externalApproval__when__checkTrusted__then__returnsFalse() throws Exception {
+ ForkPullRequestDiscoveryTrait.TrustExternalApproval trust =
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval();
+ assertThat(trust.isRequireApprovalForNewCommits(), is(false));
+ trust.setRequireApprovalForNewCommits(true);
+ assertThat(trust.isRequireApprovalForNewCommits(), is(true));
+ }
+
+ @Test
+ public void given__externalApproval__when__autoApprovalUsers__then__configuredCorrectly() throws Exception {
+ ForkPullRequestDiscoveryTrait.TrustExternalApproval trust =
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval();
+ assertThat(trust.getAutoApprovalUsers(), org.hamcrest.Matchers.nullValue());
+
+ trust.setAutoApprovalUsersList(java.util.Arrays.asList("user1", "user2"));
+ assertThat(trust.getAutoApprovalUsers(), is(java.util.Arrays.asList("user1", "user2")));
+ assertThat(trust.getAutoApprovalUsersString(), is("user1, user2"));
+
+ trust.setAutoApprovalUsersList(java.util.Collections.emptyList());
+ assertThat(trust.getAutoApprovalUsers(), org.hamcrest.Matchers.nullValue());
+
+ trust.setAutoApprovalUsersList(null);
+ assertThat(trust.getAutoApprovalUsers(), org.hamcrest.Matchers.nullValue());
+ }
+
+ @Test
+ public void given__externalApproval__when__autoApprovalUsersString__then__parsedCorrectly() throws Exception {
+ ForkPullRequestDiscoveryTrait.TrustExternalApproval trust =
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval();
+
+ trust.setAutoApprovalUsers("user1, user2, user3");
+ assertThat(trust.getAutoApprovalUsers(), is(java.util.Arrays.asList("user1", "user2", "user3")));
+
+ trust.setAutoApprovalUsers(" user1 , user2 ");
+ assertThat(trust.getAutoApprovalUsers(), is(java.util.Arrays.asList("user1", "user2")));
+
+ trust.setAutoApprovalUsers("");
+ assertThat(trust.getAutoApprovalUsers(), org.hamcrest.Matchers.nullValue());
+
+ trust.setAutoApprovalUsers((String) null);
+ assertThat(trust.getAutoApprovalUsers(), org.hamcrest.Matchers.nullValue());
+ }
+
+ @Test
+ public void given__externalApproval__when__autoApprovalLabels__then__configuredCorrectly() throws Exception {
+ ForkPullRequestDiscoveryTrait.TrustExternalApproval trust =
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval();
+ assertThat(trust.getAutoApprovalLabels(), org.hamcrest.Matchers.nullValue());
+
+ trust.setAutoApprovalLabelsList(java.util.Arrays.asList("safe-to-build", "approved"));
+ assertThat(trust.getAutoApprovalLabels(), is(java.util.Arrays.asList("safe-to-build", "approved")));
+ assertThat(trust.getAutoApprovalLabelsString(), is("safe-to-build, approved"));
+
+ trust.setAutoApprovalLabelsList(java.util.Collections.emptyList());
+ assertThat(trust.getAutoApprovalLabels(), org.hamcrest.Matchers.nullValue());
+
+ trust.setAutoApprovalLabels("label1, label2");
+ assertThat(trust.getAutoApprovalLabels(), is(java.util.Arrays.asList("label1", "label2")));
+
+ trust.setAutoApprovalLabels((String) null);
+ assertThat(trust.getAutoApprovalLabels(), org.hamcrest.Matchers.nullValue());
+ }
+
+ @Test
+ public void xstreamExternalApproval() throws Exception {
+ ForkPullRequestDiscoveryTrait.TrustExternalApproval trust =
+ new ForkPullRequestDiscoveryTrait.TrustExternalApproval();
+ trust.setRequireApprovalForNewCommits(true);
+ trust.setAutoApprovalLabelsList(java.util.Arrays.asList("safe-to-build", "ci-approved"));
+ trust.setAutoApprovalUsersList(java.util.Arrays.asList("octocat", "dependabot"));
+ String xml = new XStream2().toXML(new ForkPullRequestDiscoveryTrait(3, trust));
+ assertThat(xml, org.hamcrest.Matchers.containsString("TrustExternalApproval"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("requireApprovalForNewCommits"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("autoApprovalLabels"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("safe-to-build"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("ci-approved"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("autoApprovalUsers"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("octocat"));
+ assertThat(xml, org.hamcrest.Matchers.containsString("dependabot"));
+ }
}
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalActionTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalActionTest.java
new file mode 100644
index 000000000..513e87b67
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/PendingApprovalActionTest.java
@@ -0,0 +1,108 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2026 Olivier Lamy
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.jenkinsci.plugins.github_branch_source;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+import hudson.model.FreeStyleProject;
+import java.io.File;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+public class PendingApprovalActionTest {
+
+ @Rule
+ public JenkinsRule r = new JenkinsRule();
+
+ @Test
+ public void approvalDataPersistence() throws Exception {
+ FreeStyleProject job = r.createFreeStyleProject("test-pr-job");
+
+ assertThat(PendingApprovalAction.ApprovalData.exists(job), is(false));
+
+ PendingApprovalAction.ApprovalData data = new PendingApprovalAction.ApprovalData();
+ data.state = PendingApprovalAction.ApprovalState.PENDING;
+ data.save(job);
+
+ assertThat(PendingApprovalAction.ApprovalData.exists(job), is(true));
+ assertThat(new File(job.getRootDir(), "pending-approval.xml").exists(), is(true));
+
+ PendingApprovalAction.ApprovalData loaded = PendingApprovalAction.ApprovalData.load(job);
+ assertThat(loaded.state, is(PendingApprovalAction.ApprovalState.PENDING));
+ assertThat(loaded.approvedBy, nullValue());
+
+ loaded.state = PendingApprovalAction.ApprovalState.APPROVED;
+ loaded.approvedBy = "admin";
+ loaded.approvedAt = 1234567890L;
+ loaded.approvedPullHash = "abc123";
+ loaded.save(job);
+
+ PendingApprovalAction.ApprovalData reloaded = PendingApprovalAction.ApprovalData.load(job);
+ assertThat(reloaded.state, is(PendingApprovalAction.ApprovalState.APPROVED));
+ assertThat(reloaded.approvedBy, is("admin"));
+ assertThat(reloaded.approvedAt, is(1234567890L));
+ assertThat(reloaded.approvedPullHash, is("abc123"));
+
+ PendingApprovalAction.ApprovalData.delete(job);
+ assertThat(PendingApprovalAction.ApprovalData.exists(job), is(false));
+ }
+
+ @Test
+ public void actionProperties() {
+ PendingApprovalAction pending = new PendingApprovalAction(
+ null, PendingApprovalAction.ApprovalState.PENDING, 42, "fork-author", "deadbeef", false);
+ assertThat(pending.getState(), is(PendingApprovalAction.ApprovalState.PENDING));
+ assertThat(pending.getPrNumber(), is(42));
+ assertThat(pending.getPrAuthor(), is("fork-author"));
+ assertThat(pending.getCurrentPullHash(), is("deadbeef"));
+ assertThat(pending.isRequireApprovalForNewCommits(), is(false));
+ assertThat(pending.getDisplayName(), is(Messages.PendingApprovalAction_displayName()));
+ assertThat(pending.getIconFileName(), notNullValue());
+ assertThat(pending.getUrlName(), is("pendingApproval"));
+
+ PendingApprovalAction approved = new PendingApprovalAction(
+ null, PendingApprovalAction.ApprovalState.APPROVED, 42, "fork-author", "deadbeef", false);
+ assertThat(approved.getState(), is(PendingApprovalAction.ApprovalState.APPROVED));
+ assertThat(approved.getDisplayName(), is(Messages.PendingApprovalAction_approved()));
+ assertThat(approved.getIconFileName(), nullValue());
+ }
+
+ @Test
+ public void externalApprovalCause() {
+ PendingApprovalAction.ExternalApprovalCause cause = new PendingApprovalAction.ExternalApprovalCause();
+ assertThat(cause.getShortDescription(), is("External approval granted"));
+ }
+
+ @Test
+ public void regularJobsAreLeftAlone() throws Exception {
+ FreeStyleProject job = r.createFreeStyleProject("regular-job");
+ new PendingApprovalAction.ApprovalItemListener().onCreated(job);
+ assertThat(job.isDisabled(), is(false));
+ assertThat(job.getAction(PendingApprovalAction.class), nullValue());
+ }
+}