Skip to content
Merged
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
69 changes: 50 additions & 19 deletions .github/workflows/kapi-agent-formal-approval-gate.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: kapi-agent formal approval gate

on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
check_run:
types: [completed]
pull_request_review:
types: [submitted, edited, dismissed]

Expand All @@ -17,17 +17,34 @@ jobs:
require-formal-kapi-agent-approval:
name: require formal kapi-agent approval
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request_review' ||
(
github.event_name == 'check_run' &&
github.event.check_run.name == 'kapi-agent/review'
)
steps:
- name: Require formal current-head kapi-agent approval
uses: actions/github-script@v7
with:
script: |
const reviewer = 'kapi-agent';
const requiredCheck = 'kapi-agent/review';
const pr = context.payload.pull_request;
if (!pr) core.setFailed('This workflow must run on pull_request or pull_request_review events.');
const maxAttempts = 12;
const retryDelayMs = 10_000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const { owner, repo } = context.repo;
const pull = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
const prNumber = context.payload.pull_request?.number || context.payload.check_run?.pull_requests?.[0]?.number;
if (!prNumber) {
core.setFailed('Could not determine pull request number from event payload.');
return;
}
if (context.eventName === 'check_run' && context.payload.check_run?.name !== requiredCheck) {
core.info(`Ignoring unrelated check_run: ${context.payload.check_run?.name || 'unknown'}`);
return;
}

const pull = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data;
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
Expand All @@ -45,40 +62,54 @@ jobs:
}
}
`;
const data = await github.graphql(query, { owner, repo, number: pr.number });
const data = await github.graphql(query, { owner, repo, number: prNumber });
const prNode = data.repository.pullRequest;
const reviews = prNode.reviews.nodes || [];
const comments = prNode.comments.nodes || [];
const checkRuns = (await github.rest.checks.listForRef({ owner, repo, ref: pull.head.sha, per_page: 100 })).data.check_runs;
const statuses = (await github.rest.repos.getCombinedStatusForRef({ owner, repo, ref: pull.head.sha })).data.statuses;
const latestReview = reviews
.filter((review) => review.author?.login === reviewer)
.sort((a, b) => String(a.submittedAt || '').localeCompare(String(b.submittedAt || '')))
.at(-1);
const checkRun = checkRuns
.filter((check) => check.name === requiredCheck)
.sort((a, b) => String(a.completed_at || a.started_at || '').localeCompare(String(b.completed_at || b.started_at || '')) || Number(a.id) - Number(b.id))
.at(-1);
const status = statuses
.filter((item) => item.context === requiredCheck)
.sort((a, b) => String(a.updated_at || a.created_at || '').localeCompare(String(b.updated_at || b.created_at || '')) || Number(a.id) - Number(b.id))
.at(-1);
const checkState = checkRun?.conclusion || checkRun?.status || status?.state;

async function getRequiredCheckState() {
const checkRuns = (await github.rest.checks.listForRef({ owner, repo, ref: pull.head.sha, per_page: 100 })).data.check_runs;
const statuses = (await github.rest.repos.getCombinedStatusForRef({ owner, repo, ref: pull.head.sha })).data.statuses;
const checkRun = checkRuns
.filter((check) => check.name === requiredCheck)
.sort((a, b) => String(a.completed_at || a.started_at || '').localeCompare(String(b.completed_at || b.started_at || '')) || Number(a.id) - Number(b.id))
.at(-1);
const status = statuses
.filter((item) => item.context === requiredCheck)
.sort((a, b) => String(a.updated_at || a.created_at || '').localeCompare(String(b.updated_at || b.created_at || '')) || Number(a.id) - Number(b.id))
.at(-1);
return checkRun?.conclusion || checkRun?.status || status?.state;
}

let checkState;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
checkState = await getRequiredCheckState();
if (checkState === 'success' || checkState === 'failure' || checkState === 'error' || checkState === 'cancelled' || checkState === 'skipped') {
break;
}
core.info(`${requiredCheck} is ${checkState || 'missing'}; waiting before final approval gate attempt ${attempt}/${maxAttempts}.`);
if (attempt < maxAttempts) await sleep(retryDelayMs);
}

const approvalCommentCount = comments.filter((comment) => /^## kapi-agent review\s*\n\s*\n\*\*Verdict:\*\*\s*APPROVE(?:\s*\n|\s*$)/i.test(String(comment.body || '').trimStart())).length;
const diagnostics = [];
if (pull.draft) diagnostics.push('PR is draft');
if (!latestReview) diagnostics.push(`missing formal Pull Request Review by ${reviewer}`);
if (latestReview && latestReview.state !== 'APPROVED') diagnostics.push(`latest formal ${reviewer} review is ${latestReview.state}`);
if (latestReview && latestReview.commit?.oid !== pull.head.sha) diagnostics.push(`latest formal ${reviewer} review is not for current head ${pull.head.sha}`);
if (!checkState) diagnostics.push(`missing ${requiredCheck} check/status on current head`);
if (!checkState) diagnostics.push(`missing ${requiredCheck} check/status on current head after waiting`);
if (checkState && checkState !== 'success') diagnostics.push(`${requiredCheck} is ${checkState}`);
const summaryItems = diagnostics.length ? [...diagnostics] : ['pass'];
if (approvalCommentCount) {
summaryItems.push(`ignored ${approvalCommentCount} approval-shaped comment(s); comments are not PR reviews`);
}
await core.summary
.addHeading('kapi-agent formal approval gate')
.addRaw(`PR: #${pr.number}\n`)
.addRaw(`PR: #${prNumber}\n`)
.addRaw(`Head: ${pull.head.sha}\n`)
.addRaw(`Latest formal review: ${latestReview ? `${latestReview.author?.login} / ${latestReview.state} / ${latestReview.commit?.oid || 'no-commit'}` : 'missing'}\n`)
.addRaw(`Required check: ${checkState || 'missing'}\n`)
Expand Down
Loading