feat: define runtime event taxonomy #360
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: kapi-agent formal approval gate | |
| on: | |
| check_run: | |
| types: [completed] | |
| pull_request_review: | |
| types: [submitted, edited, dismissed] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: read | |
| checks: read | |
| statuses: read | |
| 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 maxAttempts = 12; | |
| const retryDelayMs = 10_000; | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| const { owner, repo } = context.repo; | |
| 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) { | |
| pullRequest(number: $number) { | |
| comments(last: 100) { nodes { body } } | |
| reviews(last: 100) { | |
| nodes { | |
| author { login } | |
| state | |
| submittedAt | |
| commit { oid } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| 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 latestReview = reviews | |
| .filter((review) => review.author?.login === reviewer) | |
| .sort((a, b) => String(a.submittedAt || '').localeCompare(String(b.submittedAt || ''))) | |
| .at(-1); | |
| 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 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: #${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`) | |
| .addList(summaryItems) | |
| .write(); | |
| if (diagnostics.length) core.setFailed(diagnostics.join('; ')); |