Organization-funded Copilot reviews #238
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
| # Bot-requested reviews are billed to the organization, unlike ruleset reviews. | |
| # See docs/ci/organization-funded-copilot-reviews.md before enabling writes. | |
| name: Organization-funded Copilot reviews | |
| on: | |
| pull_request_target: | |
| branches: [main, 'release/**'] | |
| # opened: a contributor creates a PR (including a draft); request its initial review. | |
| # synchronize: commits are pushed or force-pushed to the PR branch; review the new head. | |
| types: [opened, synchronize] | |
| # Reconcile pushes made while a review was pending, and events coalesced by | |
| # concurrency. Never consume PR artifacts or run a PR's workflow/code. | |
| schedule: | |
| - cron: '7,22,37,52 * * * *' | |
| workflow_dispatch: | |
| permissions: {} | |
| # Only one request-making run executes at a time, across all PRs, so a push | |
| # handler and the scheduled scan cannot both request a review for the same PR. | |
| # Runs submit requests without waiting for Copilot: reviews of different PRs | |
| # can still proceed concurrently after those requests are accepted. | |
| # Keep the active run, but GitHub may replace an older queued run with a newer | |
| # one. The scheduled scan catches any PR updates whose queued runs were replaced. | |
| concurrency: | |
| group: organization-funded-copilot-reviews | |
| cancel-in-progress: false | |
| jobs: | |
| request-reviews: | |
| if: >- | |
| github.repository == 'microsoft/aspire' && | |
| vars.COPILOT_REVIEW_MODE != 'disabled' && | |
| (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| pull-requests: write | |
| steps: | |
| # Intentionally inline: this privileged workflow never checks out code. | |
| - name: Reconcile Copilot reviews | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| COPILOT_REVIEW_MODE: ${{ vars.COPILOT_REVIEW_MODE }} | |
| COPILOT_REVIEW_PR_NUMBER: ${{ vars.COPILOT_REVIEW_PR_NUMBER }} | |
| with: | |
| github-token: ${{ github.token }} | |
| # A timed-out POST may already have started a billable review. | |
| retries: 0 | |
| script: | | |
| const owner = 'microsoft'; | |
| const repo = 'aspire'; | |
| const reviewer = 'copilot-pull-request-reviewer[bot]'; | |
| const mode = process.env.COPILOT_REVIEW_MODE || 'dry-run'; | |
| const staleCutoff = Date.now() - 14 * 24 * 60 * 60 * 1000; | |
| if (!['disabled', 'dry-run', 'enabled'].includes(mode)) { | |
| throw new Error('COPILOT_REVIEW_MODE must be disabled, dry-run, or enabled.'); | |
| } | |
| if (context.repo.owner !== owner || context.repo.repo !== repo) { | |
| throw new Error('This workflow only operates on microsoft/aspire.'); | |
| } | |
| if (mode === 'disabled') { | |
| core.info('Copilot review automation is disabled.'); | |
| return; | |
| } | |
| if (!['pull_request_target', 'schedule', 'workflow_dispatch'].includes(context.eventName) || | |
| (context.eventName === 'workflow_dispatch' && context.ref !== 'refs/heads/main')) { | |
| throw new Error('Unsupported event or workflow dispatch ref.'); | |
| } | |
| // Optional pilot scope: a positive decimal PR number, e.g. "12345". | |
| // Reject whitespace, signs, and trailing text instead of parseInt's | |
| // permissive truncation (e.g. "12345-untrusted" must not select a PR). | |
| const pilotValue = process.env.COPILOT_REVIEW_PR_NUMBER || ''; | |
| const pilot = pilotValue === '' ? null : Number(pilotValue); | |
| if (pilot !== null && (!/^[1-9][0-9]*$/.test(pilotValue) || !Number.isSafeInteger(pilot))) { | |
| throw new Error('COPILOT_REVIEW_PR_NUMBER must be a positive decimal PR number.'); | |
| } | |
| const rows = []; | |
| function record(number, sha, decision) { | |
| core.info(`PR #${number} (${sha}): ${decision}`); | |
| rows.push([String(number), sha, decision]); | |
| } | |
| function isCopilot(user) { | |
| return user?.type === 'Bot' && user.login === reviewer; | |
| } | |
| function validatePull(pull, number) { | |
| if (pull.number !== number || pull.base?.repo?.full_name !== `${owner}/${repo}` || | |
| !/^[a-f0-9]{40}$/.test(pull.head?.sha ?? '')) { | |
| throw new Error(`Invalid repository, number, or head SHA for PR #${number}.`); | |
| } | |
| if (!Array.isArray(pull.requested_reviewers)) { | |
| throw new Error(`Missing requested reviewers for PR #${number}.`); | |
| } | |
| } | |
| function ineligibleReason(pull) { | |
| if (pull.state !== 'open') { | |
| return 'Skipped: PR is not open.'; | |
| } | |
| if (pull.base.ref !== 'main' && !pull.base.ref.startsWith('release/')) { | |
| return 'Skipped: target branch is outside main/release.'; | |
| } | |
| if (context.eventName === 'schedule') { | |
| // GitHub reports PR activity as an ISO timestamp, e.g. | |
| // updated_at: "2026-09-08T19:40:00Z". Use it rather than | |
| // creation/commit dates so activity can revive an old PR. | |
| const updatedAt = Date.parse(pull.updated_at); | |
| if (!Number.isFinite(updatedAt)) { | |
| throw new Error(`Invalid updated_at timestamp for PR #${pull.number}.`); | |
| } | |
| if (updatedAt <= staleCutoff) { | |
| return 'Skipped: no PR activity in the last 14 days.'; | |
| } | |
| } | |
| return null; | |
| } | |
| async function reconcile(number) { | |
| if (!Number.isSafeInteger(number) || number <= 0) { | |
| throw new Error('Invalid PR number.'); | |
| } | |
| if (pilot !== null && number !== pilot) { | |
| record(number, '-', 'Skipped: outside pilot scope.'); | |
| return; | |
| } | |
| const args = { owner, repo, pull_number: number }; | |
| const { data: pull } = await github.rest.pulls.get(args); | |
| validatePull(pull, number); | |
| const sha = pull.head.sha; | |
| const reason = ineligibleReason(pull); | |
| if (reason) { | |
| record(number, sha, reason); | |
| return; | |
| } | |
| if (pull.requested_reviewers.some(isCopilot)) { | |
| record(number, sha, 'Deferred: Copilot review is pending.'); | |
| return; | |
| } | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { ...args, per_page: 100 }); | |
| // Dismissed reviews still consumed a review for this SHA. Human | |
| // comments and reviews of an older head do not suppress new work. | |
| if (reviews.some(review => isCopilot(review.user) && | |
| review.commit_id === sha && review.state !== 'PENDING')) { | |
| record(number, sha, 'Skipped: Copilot already reviewed this head.'); | |
| return; | |
| } | |
| // Re-fetch before writing: the PR may have changed while we read | |
| // reviews. A later scheduled scan handles a changed head safely. | |
| const { data: current } = await github.rest.pulls.get(args); | |
| validatePull(current, number); | |
| if (ineligibleReason(current) || current.head.sha !== sha || | |
| current.base.ref !== pull.base.ref || current.requested_reviewers.some(isCopilot)) { | |
| record(number, sha, 'Deferred: PR changed during reconciliation.'); | |
| return; | |
| } | |
| if (mode === 'dry-run') { | |
| record(number, sha, 'Dry run: would request Copilot review.'); | |
| return; | |
| } | |
| await github.rest.pulls.requestReviewers({ ...args, reviewers: [reviewer] }); | |
| record(number, sha, 'Requested Copilot review as the Actions bot.'); | |
| } | |
| if (context.eventName === 'pull_request_target') { | |
| await reconcile(context.payload.pull_request?.number); | |
| } else if (pilot !== null) { | |
| await reconcile(pilot); | |
| } else { | |
| for await (const page of github.paginate.iterator(github.rest.pulls.list, { | |
| owner, repo, state: 'open', sort: 'created', direction: 'asc', per_page: 100 | |
| })) { | |
| for (const pull of page.data) { | |
| await reconcile(pull.number); | |
| } | |
| } | |
| } | |
| await core.summary | |
| .addHeading(`Organization-funded Copilot reviews (${mode})`) | |
| .addTable([ | |
| [{ data: 'PR', header: true }, { data: 'Head SHA', header: true }, { data: 'Decision', header: true }], | |
| ...rows | |
| ]) | |
| .write(); |