Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions .github/extensions/open-pr-board/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,29 +61,40 @@ 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
calibrate AI review strictness.
`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 explicitly associated with the PR
and awaiting execution approval at the reviewed commit before requesting auto-merge.
Unassociated runs remain available for individual confirmation in Checks. 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
Expand Down Expand Up @@ -182,8 +193,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
Expand Down
7 changes: 7 additions & 0 deletions .github/extensions/open-pr-board/lib/decisions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
98 changes: 79 additions & 19 deletions .github/extensions/open-pr-board/lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, { requirePullRequestAssociation: true });
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, approvalMode: "automatic",
}, runner);
Comment thread
aaronpowell marked this conversation as resolved.
Resolved
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}`,
};
}
Expand All @@ -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"]);
Expand Down Expand Up @@ -176,41 +196,77 @@ 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 hasPullRequestAssociation(run, pr) {
return Array.isArray(run.pull_requests) && run.pull_requests.some((entry) => entry.number === pr.number);
}

function mayBeAssociatedWithPullRequest(run, pr) {
return run.pull_requests == null ||
(Array.isArray(run.pull_requests) &&
(run.pull_requests.length === 0 || hasPullRequestAssociation(run, pr)));
}

function isWorkflowApprovalRun(run, pr, { requirePullRequestAssociation = false } = {}) {
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));
(!requirePullRequestAssociation || hasPullRequestAssociation(run, pr));
Comment thread
aaronpowell marked this conversation as resolved.
Resolved
}

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) {
async function findLatestWorkflowApprovalCandidates(repo, pr, runner) {
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();
for (const run of pages.flatMap((page) => page.workflow_runs)) {
if (run.head_sha !== pr.head.sha || run.head_branch !== pr.head.ref ||
if (!Number.isSafeInteger(run.id) || run.id <= 0 ||
!Number.isSafeInteger(run.workflow_id) || run.workflow_id <= 0 ||
run.head_sha !== pr.head.sha || run.head_branch !== pr.head.ref ||
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))) continue;
!mayBeAssociatedWithPullRequest(run, pr)) continue;
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) => ({
return [...latest.values()];
}

export async function listWorkflowApprovals(repo, pr, runner = runGh, { requirePullRequestAssociation = false } = {}) {
const candidates = await findLatestWorkflowApprovalCandidates(repo, pr, runner);
const eligible = [];
for (const run of candidates) {
if (isWorkflowApprovalRun(run, pr, { requirePullRequestAssociation }) &&
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,
}));
}

export async function approveWorkflowRun(repo, number, { runId, attempt, headRefOid } = {}, runner = runGh) {
export async function approveWorkflowRun(repo, number, {
runId, attempt, headRefOid, approvalMode = "confirmed",
} = {}, runner = runGh) {
if (!headRefOid || !Number.isSafeInteger(runId) || runId <= 0 ||
!Number.isSafeInteger(attempt) || attempt <= 0) {
!Number.isSafeInteger(attempt) || attempt <= 0 ||
!["confirmed", "automatic"].includes(approvalMode)) {
throw expectedNotice("Refresh and select a workflow run with its current commit and attempt.");
}
const repository = JSON.parse(await runner(["api", `repos/${repo}`]));
Expand All @@ -221,12 +277,16 @@ export async function approveWorkflowRun(repo, number, { runId, attempt, headRef
if (pr.state !== "open" || pr.head?.sha !== headRefOid) {
throw expectedNotice("The pull request changed or is no longer open. Refresh before approving workflow execution.");
}
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.");
const latestRuns = await findLatestWorkflowApprovalCandidates(repo, pr, runner);
const latest = latestRuns.find((run) => run.id === runId);
if (!latest || latest.run_attempt !== attempt ||
!isWorkflowApprovalRun(latest, pr, { requirePullRequestAssociation: approvalMode === "automatic" })) {
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 || !mayBeAssociatedWithPullRequest(run, pr) || !isWorkflowApprovalRun(run, pr, {
requirePullRequestAssociation: approvalMode === "automatic",
}) || 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.
Expand All @@ -237,7 +297,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" };
}
Expand Down
Loading
Loading