From b95ad2d2c44543f9aa018f954bd4885711d47bc6 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 9 Nov 2026 16:52:25 +1100 Subject: [PATCH 1/4] feat(open-pr-board): approve pending workflows before auto-merge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/open-pr-board/README.md | 27 ++-- .../open-pr-board/lib/decisions.mjs | 7 + .../extensions/open-pr-board/lib/github.mjs | 58 +++++++-- .../open-pr-board/lib/review-prompt.mjs | 4 +- .../extensions/open-pr-board/public/app.js | 16 ++- .../open-pr-board/public/index.html | 4 +- eng/board-bulk-actions.test.mjs | 2 + eng/board-preview.test.mjs | 16 ++- eng/open-pr-board-workflow-approval.test.mjs | 123 ++++++++++++++++-- 9 files changed, 219 insertions(+), 38 deletions(-) diff --git a/.github/extensions/open-pr-board/README.md b/.github/extensions/open-pr-board/README.md index a2f375cae6..219064fd14 100644 --- a/.github/extensions/open-pr-board/README.md +++ b/.github/extensions/open-pr-board/README.md @@ -61,10 +61,10 @@ authenticated `gh` CLI. Use Left/Right arrows or Home/End to switch detail tabs. Clicking outside the drawer or successfully submitting an item action closes it; failed actions leave it open so you can correct or retry them. -- **Approve and run checks** — cards identify fork workflow runs awaiting execution +- **Approve and run checks** — cards identify workflow runs awaiting execution approval. Open **Checks**, inspect the linked workflow and contributor code, then explicitly confirm **Approve and run checks** for a specific run, attempt, and - commit. This uses GitHub's fork workflow approval endpoint, not a PR approving + commit. This uses GitHub's workflow approval endpoint via `gh api`, not a PR approving review, re-run, auto-merge, or required-check bypass. The board refreshes after success and records workflow execution approval in history without changing the PR's triage bucket, review queue, or staged feedback. Execution events do not @@ -72,18 +72,28 @@ authenticated `gh` CLI. `ACTION_REQUIRED` checks are shown as needing attention, not test failures: submission gates can require PR reviews rather than execution approval. Queued/running checks, actual failures, deployment/environment approvals, private - forks, and non-fork runs are not eligible. Only the latest run for each workflow - associated with this PR at the current fork head is offered; runs associated only + forks are not eligible. Public same-repository PRs (including bot-created external + plugin PRs) and public forks are supported. Only `pull_request` runs with no jobs + and an `action_required` conclusion are offered, so executed submission gates + are not mistaken for execution approvals. Only the latest run for each workflow + associated with this PR at the current head is offered; runs associated only with another PR do not displace it. Discovery errors remain visible as a fixed failure indicator, without retaining raw tool errors; refresh to retry. - Unassociated fork runs are matched by repository IDs, branch, and exact SHA. + Unassociated runs are matched by repository IDs, branch, and exact SHA. The authenticated `gh` account needs repository write access and token access to Actions; GitHub enforces final eligibility and permissions. Errors are surfaced, never automatically retried. After an unconfirmed write, inspect the run on GitHub before retrying. Run state and PR head are checked again immediately before the write, but GitHub's approval API provides no atomic SHA/attempt precondition. - **GitHub actions** — submit feedback, request changes, close a PR, or approve it and - enable auto-merge with a merge commit. Each action needs two clicks to confirm and + enable auto-merge with a merge commit. **Approve + auto-merge**, individually or + in bulk, also discovers and authorizes workflows awaiting execution approval at + the reviewed commit before requesting auto-merge. Confirming this action authorizes + running contributor code; required checks are not bypassed. Discovery failure stops + before submitting a review. A workflow approval failure leaves the PR review + submitted, reports the error, and does not request auto-merge. Successful workflow + approvals are recorded separately in history, including partial successes. + Each action needs two clicks to confirm and is also recorded in local board history. Approval is pinned to the board's commit SHA displayed by the client, and auto-merge requires that same head commit. A changed head automatically reloads an open preview; approval is unavailable until @@ -182,8 +192,9 @@ automatic refresh cadence, concurrent/manual refreshes, state preservation, erro Run `node --test eng/board-column-order.test.mjs` to check oldest-first column ordering. Run `node --test eng/open-pr-board.test.mjs` from the repository root. GitHub writes are tested with injected in-process fakes, never the authenticated CLI. -Run `node --test eng/open-pr-board-workflow-approval.test.mjs` to check fork workflow -discovery, explicit confirmation, stale state and permission guards, shared action +Run `node --test eng/open-pr-board-workflow-approval.test.mjs` to check fork and +same-repository workflow discovery, combined approval and auto-merge, explicit +confirmation, stale state and permission guards, shared action locking, success, and errors without remote mutations. Run `node --test eng/open-pr-board-prompt-security.test.mjs` to check bounded, untrusted prompt metadata, safe discovery-error persistence, and calibration that diff --git a/.github/extensions/open-pr-board/lib/decisions.mjs b/.github/extensions/open-pr-board/lib/decisions.mjs index fd15a6b138..b4afdb166b 100644 --- a/.github/extensions/open-pr-board/lib/decisions.mjs +++ b/.github/extensions/open-pr-board/lib/decisions.mjs @@ -83,6 +83,13 @@ export async function applyDecisionOnGitHub({ store, number, kind, comment, head } const warnings = result.warning ? [result.warning] : []; + for (const approval of result.workflowApprovals ?? []) { + try { + await store.recordWorkflowApproval(number, approval); + } catch (error) { + warnings.push(`Workflow execution was approved, but the board could not record it: ${error.message}`); + } + } try { await store.recordDecision(number, { kind, comment: body, status: result.status, headRefOid: actionHeadRefOid }); } catch (error) { diff --git a/.github/extensions/open-pr-board/lib/github.mjs b/.github/extensions/open-pr-board/lib/github.mjs index cea4db6dca..8da6f2ca17 100644 --- a/.github/extensions/open-pr-board/lib/github.mjs +++ b/.github/extensions/open-pr-board/lib/github.mjs @@ -73,15 +73,35 @@ export async function approveAndEnableAutoMerge(repo, number, { body, headRefOid if (current.state !== "open" || current.head?.sha !== headRefOid) { throw expectedNotice("The pull request changed or is no longer open. Refresh and review it again before approving."); } + const workflows = await listWorkflowApprovals(repo, current, runner); await runner(["api", `repos/${repo}/pulls/${number}/reviews`, "--method", "POST", "--input", "-"], { input: JSON.stringify({ event: "APPROVE", commit_id: headRefOid, body: String(body ?? "").trim() }), }); + const workflowApprovals = []; + for (const workflow of workflows) { + try { + const result = await approveWorkflowRun(repo, number, { + runId: workflow.id, attempt: workflow.attempt, headRefOid, + }, runner); + workflowApprovals.push({ + runId: workflow.id, attempt: workflow.attempt, headRefOid, status: result.status, + }); + } catch (error) { + return { + status: "approval-submitted", + workflowApprovals, + warning: `The approving review was submitted, but workflow "${workflow.name}" could not be approved. Auto-merge was not requested: ${error.message}`, + }; + } + } + try { await runner(["pr", "merge", String(number), "-R", repo, "--auto", "--merge", "--match-head-commit", headRefOid]); } catch (error) { return { status: "approval-submitted", + workflowApprovals, warning: `The approving review was submitted, but auto-merge could not be enabled: ${error.message}`, }; } @@ -97,12 +117,12 @@ export async function approveAndEnableAutoMerge(repo, number, { body, headRefOid "state,mergedAt,autoMergeRequest", ]); const state = JSON.parse(raw); - if (state.mergedAt || state.state === "MERGED") return { status: "merged" }; - if (state.autoMergeRequest) return { status: "auto-merge-enabled" }; + if (state.mergedAt || state.state === "MERGED") return { status: "merged", workflowApprovals }; + if (state.autoMergeRequest) return { status: "auto-merge-enabled", workflowApprovals }; } catch { // The merge request succeeded; inability to refine its display status is non-fatal. } - return { status: "merge-requested" }; + return { status: "merge-requested", workflowApprovals }; } const FAILING_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "STALE", "STARTUP_FAILURE"]); @@ -176,22 +196,33 @@ function normalizePullRequest(raw) { }; } -// A check's ACTION_REQUIRED conclusion alone is not evidence of fork approval: +// A check's ACTION_REQUIRED conclusion alone is not evidence of execution approval: // submission gates and other apps use it for unrelated maintainer decisions. -function isForkApprovalRun(run, pr) { +function isWorkflowApprovalRun(run, pr) { return Number.isSafeInteger(run.id) && run.id > 0 && Number.isSafeInteger(run.workflow_id) && run.workflow_id > 0 && Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0 && run.event === "pull_request" && run.status === "completed" && run.conclusion === "action_required" && run.head_sha === pr.head?.sha && run.head_branch === pr.head?.ref && - pr.head?.repo?.private === false && pr.head.repo.id !== pr.base?.repo?.id && + pr.head?.repo?.private === false && run.repository?.id === pr.base?.repo?.id && run.head_repository?.id === pr.head.repo.id && (!run.pull_requests?.length || run.pull_requests.some((entry) => entry.number === pr.number)); } +async function hasNoWorkflowJobs(repo, run, runner) { + const jobs = JSON.parse(await runner(["api", + `repos/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=1`])); + if (!Number.isSafeInteger(jobs.total_count) || jobs.total_count < 0 || !Array.isArray(jobs.jobs)) { + throw new Error("Could not verify whether the workflow run has executed jobs."); + } + // Approval-blocked runs have not executed jobs; a completed job may instead + // have returned action_required for a submission gate or another decision. + return jobs.total_count === 0 && jobs.jobs.length === 0; +} + export async function listWorkflowApprovals(repo, pr, runner = runGh) { if (!Number.isSafeInteger(pr.head?.repo?.id) || !Number.isSafeInteger(pr.base?.repo?.id) || - pr.head.repo.id === pr.base.repo.id || pr.head.repo.private !== false) return []; + pr.head.repo.private !== false) return []; const pages = JSON.parse(await runner(["api", "--paginate", "--slurp", `repos/${repo}/actions/runs?event=pull_request&head_sha=${encodeURIComponent(pr.head.sha)}&per_page=100`])); const latest = new Map(); @@ -202,7 +233,11 @@ export async function listWorkflowApprovals(repo, pr, runner = runGh) { const previous = latest.get(run.workflow_id); if (!previous || run.id > previous.id) latest.set(run.workflow_id, run); } - return [...latest.values()].filter((run) => isForkApprovalRun(run, pr)).map((run) => ({ + const eligible = []; + for (const run of latest.values()) { + if (isWorkflowApprovalRun(run, pr) && await hasNoWorkflowJobs(repo, run, runner)) eligible.push(run); + } + return eligible.map((run) => ({ id: run.id, attempt: run.run_attempt, name: run.name || "Workflow", url: run.html_url, headRefOid: run.head_sha, })); @@ -223,10 +258,11 @@ export async function approveWorkflowRun(repo, number, { runId, attempt, headRef } const eligible = await listWorkflowApprovals(repo, pr, runner); if (!eligible.some((run) => run.id === runId && run.attempt === attempt)) { - throw expectedNotice("This run is no longer awaiting supported fork workflow approval. Refresh; queued, failed, and deployment-waiting runs cannot be approved here."); + throw expectedNotice("This run is no longer awaiting supported workflow approval. Refresh; queued, failed, and deployment-waiting runs cannot be approved here."); } const run = JSON.parse(await runner(["api", `repos/${repo}/actions/runs/${runId}`])); - if (run.id !== runId || !isForkApprovalRun(run, pr) || run.run_attempt !== attempt) { + if (run.id !== runId || !isWorkflowApprovalRun(run, pr) || run.run_attempt !== attempt || + !await hasNoWorkflowJobs(repo, run, runner)) { throw expectedNotice("The workflow run changed or no longer needs approval. Refresh first."); } // Recheck the PR after run discovery; GitHub's approval endpoint has no SHA precondition. @@ -237,7 +273,7 @@ export async function approveWorkflowRun(repo, number, { runId, attempt, headRef try { await runner(["api", `repos/${repo}/actions/runs/${runId}/approve`, "--method", "POST"]); } catch (error) { - throw new Error(`Workflow execution approval was not confirmed. Check the run on GitHub before retrying; the gh account needs write access and an eligible fork run: ${error.message}`); + throw new Error(`Workflow execution approval was not confirmed. Check the run on GitHub before retrying; the gh account needs write access and an eligible run: ${error.message}`); } return { status: "workflow-execution-approved" }; } diff --git a/.github/extensions/open-pr-board/lib/review-prompt.mjs b/.github/extensions/open-pr-board/lib/review-prompt.mjs index 2fa7dde5f9..a4ef91e9cc 100644 --- a/.github/extensions/open-pr-board/lib/review-prompt.mjs +++ b/.github/extensions/open-pr-board/lib/review-prompt.mjs @@ -85,7 +85,7 @@ function describeItem(item) { ` labels: ${JSON.stringify(labels.length ? labels : ["none"])}`, ` diff: +${item.additions}/-${item.deletions} across ${item.changedFiles} files`, ` checks: ${checks} | review decision: ${promptData(item.reviewDecision, 50)} | merge state: ${promptData(item.mergeStateStatus, 50)}`, - ` fork workflows awaiting execution approval: ${item.workflowApprovals?.length ?? 0}${item.workflowApprovalError ? " (discovery unavailable)" : ""}`, + ` workflows awaiting execution approval: ${item.workflowApprovals?.length ?? 0}${item.workflowApprovalError ? " (discovery unavailable)" : ""}`, ...previousReview, ` url: ${promptData(item.url, 300)}`, ].join("\n"); @@ -102,7 +102,7 @@ function sharedRules(repo, guidancePath) { " - `gh pr view -R " + repo + " --comments` for the description and discussion.", " - `gh pr diff -R " + repo + "` for the actual change (use `--name-only` first if the diff is large).", " - `gh pr checks -R " + repo + "` when checks are failing, to see which ones and why.", - " - ACTION_REQUIRED is not a content-test failure: distinguish submission gates awaiting PR reviews from fork workflows awaiting execution approval. Only the maintainer's explicit board confirmation can authorize execution; never authorize it during an AI review.", + " - ACTION_REQUIRED is not a content-test failure: distinguish submission gates awaiting PR reviews from workflows awaiting execution approval, including on bot-created same-repository PRs. Only the maintainer's explicit board confirmation can authorize execution; never authorize it during an AI review.", " - `gh pr list -R " + repo + " --author --state all` to find the author's sibling PRs. If siblings were closed for a reason, apply it consistently and cite them; flag any open sibling recorded more leniently in `checkNext`.", "Judge the submitted result, not assumptions about the tool that produced it. A title ending in 🤖🤖🤖 is a disclosure, not a defect.", "Do not limit repository fit to coding or text-based developer tasks. Evaluate Copilot-supported workflows across modalities, including image, video, audio, other media generation, and creative work where applicable. Modality or non-coding use alone is not a rejection reason; assess concrete Copilot relevance, specific standalone user value, knowledge uplift, provenance, product/vendor-funnel evidence, responsible use, and validation appropriate to the workflow.", diff --git a/.github/extensions/open-pr-board/public/app.js b/.github/extensions/open-pr-board/public/app.js index 4f26898102..56933dc625 100644 --- a/.github/extensions/open-pr-board/public/app.js +++ b/.github/extensions/open-pr-board/public/app.js @@ -27,7 +27,7 @@ const DECISION_BUTTONS = { }; const CONFIRM_LABELS = { - approve: "Confirm: approve + merge", + approve: "Confirm: approve + run workflows + merge", "request-changes": "Confirm: request changes", comment: "Confirm: submit feedback", close: "Confirm: close PR", @@ -224,7 +224,7 @@ function statusPills(item) { if (item.checksState === "success" && !item.workflowApprovals?.length && !item.workflowApprovalError) add("checks pass", "success", "All checks passed"); } - if (item.workflowApprovals?.length) add(`${item.workflowApprovals.length} awaiting workflow approval`, "caution", "Open Checks to authorize fork workflow execution"); + if (item.workflowApprovals?.length) add(`${item.workflowApprovals.length} awaiting workflow approval`, "caution", "Open Checks to authorize workflow execution"); if (item.workflowApprovalError) add("workflow approvals unavailable", "caution", item.workflowApprovalError); if (item.mergeStateStatus === "DIRTY") add("conflicts", "danger", "Merge conflicts with the base branch"); if (item.reviewDecision === "APPROVED") add("approved", "success", "Approved on GitHub"); @@ -436,7 +436,7 @@ async function bulkAction(columnId, kind) { const preview = items.map((item) => `#${item.number}${kind === "rereview" ? "" : `: ${item.comment || "(no comment)"}`}`).join("\n\n"); if (!confirm(`${label} all ${items.length} PRs in this column (including hidden search results)?\n${ kind === "rereview" ? "Queues AI reviews; GitHub is not changed." : kind === "approve" ? - "Submits approvals and enables auto-merge with a merge commit." : "Uses each PR's staged comment on GitHub."}\n\n${preview}`)) return; + "Submits approvals, authorizes any workflows awaiting execution approval at each reviewed commit, and enables auto-merge with a merge commit. Review contributor code before running it; required checks are not bypassed." : "Uses each PR's staged comment on GitHub."}\n\n${preview}`)) return; bulkBusy = true; renderBoard(); if (currentItem()) renderDrawerHeader(); @@ -626,9 +626,11 @@ function checksTab(item) { {}, item.workflowApprovalError ? h("p", { class: "notice", "data-agent-error": "" }, item.workflowApprovalError) : null, item.workflowApprovals?.length ? h("div", {}, - h("p", { class: "notice" }, "Fork workflows await execution approval. Review the contributor's code before running it. This does not approve the PR, enable auto-merge, or bypass required checks."), + h("p", { class: "notice" }, "Workflows await execution approval, including workflows on bot-created PRs. Review the contributor's code before running it. Approve and run checks only authorizes execution; Approve + auto-merge also runs these workflows before requesting auto-merge. Required checks are not bypassed."), h("ul", { class: "row-list" }, item.workflowApprovals.map((workflow) => h("li", { class: "row" }, + h("span", { class: "state-dot", dataset: { state: "actionRequired" }, title: "Awaiting execution approval" }), h("a", { class: "row-name", href: workflow.url, target: "_blank", rel: "noreferrer noopener" }, `${workflow.name} (run ${workflow.id}, attempt ${workflow.attempt})`), + h("span", { class: "meta" }, "awaiting approval"), h("button", { class: "btn btn-small", type: "button", disabled: bulkBusy || decisionsInFlight.has(item.number), @@ -637,8 +639,10 @@ function checksTab(item) { ))), ) : null, item.checkCounts?.actionRequired ? h("p", { class: "notice" }, - "Action-required checks can also be submission gates awaiting PR reviews. Only eligible fork workflow runs above can be authorized here. Deployment approvals and other gates must be handled on GitHub.") : null, - !item.checks?.length ? h("p", { class: "notice" }, "No checks reported for this pull request.") : h( + "Action-required checks can also be submission gates awaiting PR reviews. Only eligible workflow runs above can be authorized here. Deployment approvals and other gates must be handled on GitHub.") : null, + !item.checks?.length ? h("p", { class: "notice" }, item.workflowApprovals?.length + ? "Workflow jobs will appear after execution is approved." + : "No checks reported for this pull request.") : h( "ul", { class: "row-list" }, item.checks.map((check) => diff --git a/.github/extensions/open-pr-board/public/index.html b/.github/extensions/open-pr-board/public/index.html index d16d4565a1..0fc19cd567 100644 --- a/.github/extensions/open-pr-board/public/index.html +++ b/.github/extensions/open-pr-board/public/index.html @@ -75,7 +75,9 @@