Repository navigation
Support uv check
#244
Workflow file for this run
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: "Automation: PR state" | ||
|
Check warning on line 1 in .github/workflows/automation_pr_state.yml
|
||
| # Runs automation based on the current PR state: | ||
| # - Updates "waiting-for-*" labels to indicate who should act next. | ||
| on: | ||
| pull_request_target: | ||
| types: | ||
| - reopened | ||
| - closed | ||
| - ready_for_review | ||
| - converted_to_draft | ||
| - review_requested | ||
| - labeled | ||
| - unlabeled | ||
| workflow_run: | ||
| workflows: ["Automation: PR review"] | ||
| types: [completed] | ||
| issue_comment: | ||
| types: [created] | ||
| # Sweeps catch up on events whose runs were missed. | ||
| schedule: | ||
| - cron: '13,43 * * * *' | ||
| workflow_dispatch: | ||
| inputs: | ||
| scenario: | ||
| description: 'Operation' | ||
| type: choice | ||
| options: | ||
| - Backfill state labels on all unlabeled PRs | ||
| - Update state label on one PR | ||
| - Reconcile PRs updated since last sweep | ||
| pr_number: | ||
| description: 'PR number (only for updating one PR)' | ||
| type: number | ||
| dry_run: | ||
| description: 'Dry run (preview labels without applying)' | ||
| type: boolean | ||
| default: true | ||
| permissions: {} | ||
| env: | ||
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | ||
| jobs: | ||
| select-prs: | ||
| if: >- | ||
| ${{ (!contains(fromJSON('["labeled", "unlabeled"]'), github.event.action) | ||
| || github.event.label.name == 'cla signed') | ||
| && (github.event_name != 'issue_comment' | ||
| || (github.event.issue.pull_request != null | ||
| && ((github.event.comment.user.login == github.event.issue.user.login | ||
| && contains(github.event.comment.body, '@')) | ||
| || (github.event.comment.user.login == 'meta-cla[bot]' | ||
| && github.event.comment.user.type == 'Bot' | ||
| && contains(github.event.comment.body, '# Action Required'))))) }} | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| actions: read # find the last successful sweep | ||
| contents: read # read owners.json once for the run | ||
| pull-requests: read | ||
| outputs: | ||
| prs: ${{ steps.select.outputs.result }} | ||
| owners: ${{ steps.select.outputs.owners }} | ||
| steps: | ||
| - name: Select PRs to evaluate | ||
| id: select | ||
| uses: actions/github-script@v8 | ||
| with: | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| script: | | ||
| const dryRun = process.env.DRY_RUN === 'true'; | ||
| // Dry runs - even from a fork - always run against facebook/pyrefly, so we can test | ||
| // the workflow from a fork. | ||
| const { owner, repo } = dryRun ? { owner: 'facebook', repo: 'pyrefly' } : context.repo; | ||
| const ownersFile = await github.rest.repos.getContent({ | ||
| owner, repo, path: '.github/owners.json', | ||
| }); | ||
| const owners = JSON.parse(Buffer.from(ownersFile.data.content, 'base64').toString('utf8')) | ||
| .map(o => o.toLowerCase()); | ||
| core.setOutput('owners', JSON.stringify(owners)); | ||
| if (context.eventName === 'pull_request_target') { | ||
| return [context.payload.pull_request.number]; | ||
| } | ||
| if (context.eventName === 'issue_comment') { | ||
| return [context.payload.issue.number]; | ||
| } | ||
| const sweep = context.eventName === 'schedule' | ||
| || context.payload.inputs?.scenario === 'Reconcile PRs updated since last sweep'; | ||
| if (context.eventName === 'workflow_dispatch') { | ||
| const { scenario, pr_number } = context.payload.inputs; | ||
| if (scenario === 'Update state label on one PR') { | ||
| if (!pr_number) throw new Error('pr_number is required to update one PR'); | ||
| return [Number(pr_number)]; | ||
| } | ||
| if (scenario !== 'Backfill state labels on all unlabeled PRs' && !sweep) { | ||
| throw new Error(`Unknown scenario: ${scenario}`); | ||
| } | ||
| if (pr_number) throw new Error('pr_number is only used to update one PR'); | ||
| } | ||
| let since = -Infinity; | ||
| if (sweep) { | ||
| const { data: { workflow_runs: [last] } } = await github.rest.actions.listWorkflowRuns({ | ||
| owner, repo, workflow_id: 'automation_pr_state.yml', | ||
| event: 'schedule', status: 'success', per_page: 1, | ||
| }); | ||
| // A run is created before it selects PRs, even when it is re-run, | ||
| // so every update after the previous sweep's selection is covered. | ||
| if (last) since = Date.parse(last.created_at); | ||
| } | ||
| const prs = await github.paginate(github.rest.pulls.list, { | ||
| owner, repo, state: 'open', sort: 'updated', direction: 'desc', per_page: 100, | ||
| }, (response, done) => { | ||
| const page = response.data.filter(pr => Date.parse(pr.updated_at) >= since); | ||
| if (page.length < response.data.length) done(); | ||
| return page; | ||
| }); | ||
| if (context.eventName !== 'workflow_run') { | ||
| const stateLabels = ['waiting-for-author', 'waiting-for-reviewer', 'waiting-for-merge']; | ||
| // An update during pagination can list a PR on two pages. | ||
| return [...new Set(prs.filter(pr => pr.user.type !== 'Bot' | ||
| && !pr.head.ref.startsWith('export-D') | ||
| && !owners.includes(pr.user.login.toLowerCase()) | ||
| && (sweep || (!pr.draft && !pr.labels.some(l => stateLabels.includes(l.name))))) | ||
| .map(pr => pr.number))]; | ||
| } | ||
| // GitHub supplies the head SHA. Nothing produced by the completed | ||
| // workflow is trusted or executed, including artifacts. | ||
| return prs.filter(pr => pr.head.sha === context.payload.workflow_run.head_sha) | ||
| .map(pr => pr.number); | ||
| evaluate: | ||
| needs: select-prs | ||
| if: ${{ needs.select-prs.outputs.prs != '[]' }} | ||
| strategy: | ||
| fail-fast: false | ||
| max-parallel: 4 | ||
| matrix: | ||
| pr: ${{ fromJSON(needs.select-prs.outputs.prs) }} | ||
| # Manual, scheduled, and event-triggered runs share the same per-PR lock. | ||
| concurrency: | ||
| group: pr-state-${{ matrix.pr }} | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| pull-requests: write # read the PR, reviews, labels, and timeline, and update labels | ||
| checks: read # distinguish unsigned CLAs from pending checks | ||
| steps: | ||
| - name: Label the PR | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PR_NUMBER: ${{ matrix.pr }} | ||
| OWNERS_JSON: ${{ needs.select-prs.outputs.owners }} | ||
| with: | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| script: | | ||
| const dryRun = process.env.DRY_RUN === 'true'; | ||
| const { owner, repo } = dryRun ? { owner: 'facebook', repo: 'pyrefly' } : context.repo; | ||
| const pull_number = Number(process.env.PR_NUMBER); | ||
| const issue_number = pull_number; | ||
| const STATE_LABELS = ['waiting-for-author', 'waiting-for-reviewer', 'waiting-for-merge']; | ||
| // Evaluate current state because the event may have waited behind | ||
| // another run for the same PR. | ||
| const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); | ||
| const owners = JSON.parse(process.env.OWNERS_JSON); | ||
| const author = pr.user.login.toLowerCase(); | ||
| if (pr.user.type === 'Bot' || pr.head.ref.startsWith('export-D') || owners.includes(author)) { | ||
| console.log(`#${pull_number}: bot, team member, or Phabricator export, skipping`); | ||
| return; | ||
| } | ||
| const labels = (await github.paginate(github.rest.issues.listLabelsOnIssue, { | ||
| owner, repo, issue_number, per_page: 100, | ||
| })).map(l => l.name); | ||
| const current = STATE_LABELS.filter(name => labels.includes(name)); | ||
| if (context.eventName === 'workflow_dispatch' | ||
| && context.payload.inputs.scenario === 'Backfill state labels on all unlabeled PRs' | ||
| && current.length > 0) { | ||
| console.log(`#${pull_number}: already labeled, leaving as-is`); | ||
| return; | ||
| } | ||
| // TODO(rechen): This check prevents non-dry runs from doing anything on PRs that don't | ||
| // already have a label; remove this once we're ready to roll out to all PRs. | ||
| if (!dryRun && current.length === 0) { | ||
| console.log(`#${pull_number}: not labeled, skipping`); | ||
| return; | ||
| } | ||
| const sweep = context.eventName === 'schedule' | ||
| || context.payload.inputs?.scenario === 'Reconcile PRs updated since last sweep'; | ||
| const manual = context.eventName === 'workflow_dispatch' && !sweep; | ||
| let timelineRequest; | ||
| const listTimeline = () => timelineRequest ??= github.paginate(github.rest.issues.listEventsForTimeline, { | ||
| owner, repo, issue_number, per_page: 100, | ||
| }); | ||
| // Relay runs and sweeps only act on events newer than the latest | ||
| // state label change, which may have been made by hand. | ||
| const changedAt = Math.max(...(sweep || context.eventName === 'workflow_run' ? await listTimeline() : []) | ||
| .filter(e => ['labeled', 'unlabeled'].includes(e.event) && STATE_LABELS.includes(e.label?.name)) | ||
| .map(e => Date.parse(e.created_at))); | ||
| // A sweep stands in for event-triggered runs that may have been | ||
| // missed, so it handles each newer triggering event the way that | ||
| // event's run does. | ||
| const newEvents = sweep | ||
| ? (await listTimeline()).filter(e => Date.parse(e.created_at ?? e.submitted_at) > changedAt) | ||
| : []; | ||
| if (sweep && newEvents.length === 0) return; | ||
| const lifecycle = (context.eventName === 'pull_request_target' | ||
| && ['reopened', 'ready_for_review', 'converted_to_draft', 'closed'].includes(context.payload.action)) | ||
| || newEvents.some(e => ['reopened', 'ready_for_review', 'convert_to_draft'].includes(e.event)); | ||
| const isClaComment = c => c.user?.login === 'meta-cla[bot]' && c.user?.type === 'Bot' | ||
| && /^# Action Required\s*$/m.test(c.body || ''); | ||
| const claChanged = (context.eventName === 'pull_request_target' | ||
| && ['labeled', 'unlabeled'].includes(context.payload.action) | ||
| && context.payload.label.name === 'cla signed') | ||
| || newEvents.some(e => (['labeled', 'unlabeled'].includes(e.event) && e.label?.name === 'cla signed') | ||
| || (e.event === 'commented' && isClaComment(e))); | ||
| const claComment = context.eventName === 'issue_comment' && isClaComment(context.payload.comment); | ||
| if (claComment && labels.includes('cla signed')) return; | ||
| if ((pr.state !== 'open' || pr.draft) && !lifecycle && !manual) { | ||
| return; | ||
| } | ||
| let state = null; | ||
| if (pr.state === 'open' && !pr.draft) { | ||
| let claSigned = labels.includes('cla signed'); | ||
| if (!claSigned && !claComment && !claChanged) { | ||
| const checks = await github.paginate(github.rest.checks.listForRef, { | ||
| owner, repo, ref: pr.head.sha, per_page: 100, | ||
| }); | ||
| const cla = checks.filter(c => c.app?.slug === 'meta-cla') | ||
| .sort((a, b) => Date.parse(b.started_at || 0) - Date.parse(a.started_at || 0))[0]; | ||
| // Missing, pending, and inconclusive checks are not unsigned. | ||
| if (!['success', 'action_required'].includes(cla?.conclusion)) return; | ||
| claSigned = cla.conclusion === 'success'; | ||
| } | ||
| if (!claSigned && (lifecycle || claChanged || claComment || manual)) { | ||
| state = 'waiting-for-author'; | ||
| } else { | ||
| const reviewers = new Set([...owners, ...pr.assignees.map(a => a.login.toLowerCase())]); | ||
| // listReviews returns reviews in chronological order. | ||
| let reviews = (await github.paginate(github.rest.pulls.listReviews, { | ||
| owner, repo, pull_number, per_page: 100, | ||
| })).filter(r => r.user | ||
| && r.user.login.toLowerCase() !== author | ||
| && reviewers.has(r.user.login.toLowerCase()) | ||
| && ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED'].includes(r.state)); | ||
| // Comments do not replace a reviewer's decision. Dismissal | ||
| // clears it, including when GitHub dismisses stale approvals | ||
| // after new commits. | ||
| const decisions = new Map(); | ||
| for (const review of reviews) { | ||
| if (review.state !== 'COMMENTED') { | ||
| decisions.set(review.user.login.toLowerCase(), review); | ||
| } | ||
| } | ||
| const approved = [...decisions.values()].some(r => r.state === 'APPROVED'); | ||
| const changesRequested = [...decisions.values()].some(r => r.state === 'CHANGES_REQUESTED'); | ||
| // Thread replies have no summary. Their threads only need to | ||
| // be read when those reviews could affect the result. | ||
| if (!(manual && approved && !changesRequested) | ||
| && reviews.some(r => r.state === 'COMMENTED' && !r.body?.trim())) { | ||
| const startsThread = new Set((await github.paginate(github.rest.pulls.listReviewComments, { | ||
| owner, repo, pull_number, per_page: 100, | ||
| })).filter(c => !c.in_reply_to_id).map(c => c.pull_request_review_id)); | ||
| reviews = reviews.filter(r => r.state !== 'COMMENTED' | ||
| || r.body?.trim() || startsThread.has(r.id)); | ||
| } | ||
| const latestFeedback = reviews.filter(r => r.state === 'COMMENTED' | ||
| || (r.state === 'CHANGES_REQUESTED' | ||
| && decisions.get(r.user.login.toLowerCase()) === r)).at(-1); | ||
| const mentionTargets = new Set([...pr.assignees.map(a => a.login.toLowerCase()), | ||
| ...reviews.filter(r => r.state !== 'DISMISSED').map(r => r.user.login.toLowerCase())]); | ||
| const authorMention = context.eventName === 'issue_comment' | ||
| && context.payload.comment.user.login.toLowerCase() === author | ||
| && [...(context.payload.comment.body || '').matchAll(/(?<![\w@])@([a-z0-9-]+)(?![\w/-])/gi)] | ||
| .some(([, login]) => mentionTargets.has(login.toLowerCase())); | ||
| if (context.eventName === 'issue_comment' && !authorMention) return; | ||
| const timeline = context.eventName !== 'workflow_run' && !sweep | ||
| && ((approved && !changesRequested) || !latestFeedback) | ||
| ? [] | ||
| : await listTimeline(); | ||
| const lastRerequest = Math.max( | ||
| authorMention ? Date.parse(context.payload.comment.created_at) : -Infinity, | ||
| ...timeline.filter(e => e.event === 'review_requested' | ||
| || (e.event === 'commented' && e.user?.login.toLowerCase() === author | ||
| && [...(e.body || '').matchAll(/(?<![\w@])@([a-z0-9-]+)(?![\w/-])/gi)] | ||
| .some(([, login]) => mentionTargets.has(login.toLowerCase())))) | ||
| .map(e => Date.parse(e.created_at))); | ||
| if (approved && !changesRequested) { | ||
| state = 'waiting-for-merge'; | ||
| } else if (latestFeedback && Date.parse(latestFeedback.submitted_at) > lastRerequest) { | ||
| state = 'waiting-for-author'; | ||
| } else { | ||
| state = 'waiting-for-reviewer'; | ||
| } | ||
| const reviewRequested = authorMention | ||
| || (context.eventName === 'pull_request_target' && context.payload.action === 'review_requested'); | ||
| if (reviewRequested) { | ||
| // With a signed CLA, a handoff can request another review | ||
| // but cannot approve a PR or undo waiting-for-merge. | ||
| if (!claSigned) { | ||
| state = 'waiting-for-author'; | ||
| } else if (current.includes('waiting-for-merge') || state !== 'waiting-for-reviewer') { | ||
| return; | ||
| } | ||
| } else if (context.eventName === 'workflow_run' || (sweep && !lifecycle && !claChanged)) { | ||
| // A relay run can also represent an ignored thread reply. | ||
| // Only reviews, dismissals, and re-requests newer than the | ||
| // latest state label change can change it. | ||
| const newReviews = reviews.filter(r => r.state !== 'DISMISSED' | ||
| && Date.parse(r.submitted_at) > changedAt); | ||
| const dismissed = timeline.some(e => e.event === 'review_dismissed' | ||
| && Date.parse(e.created_at) > changedAt | ||
| && reviews.some(r => r.id === e.dismissed_review?.review_id)); | ||
| if (newReviews.length === 0 && !dismissed && lastRerequest <= changedAt) return; | ||
| if (state === 'waiting-for-merge' | ||
| && !newReviews.some(r => r.state === 'APPROVED') && !dismissed) return; | ||
| if (state === 'waiting-for-author' && current.includes('waiting-for-merge') | ||
| && !newReviews.some(r => r.state === 'CHANGES_REQUESTED') && !dismissed) return; | ||
| // A re-request alone cannot undo waiting-for-merge. | ||
| if (current.includes('waiting-for-merge') && newReviews.length === 0 && !dismissed) return; | ||
| } else if (!lifecycle && !claChanged && !manual) { | ||
| return; | ||
| } | ||
| if (!claSigned) state = 'waiting-for-author'; | ||
| } | ||
| } | ||
| const remove = current.filter(name => name !== state); | ||
| const add = state && !labels.includes(state); | ||
| if (!dryRun) { | ||
| for (const name of remove) { | ||
| await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); | ||
| } | ||
| if (add) { | ||
| await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [state] }); | ||
| } | ||
| } | ||
| const action = remove.length || add ? (dryRun ? 'Would update' : 'Updated') : 'Unchanged'; | ||
| console.log(`#${pull_number}: ${action}: ${current.join(', ') || 'none'} -> ${state || 'none'}`); | ||
| await core.summary.addTable([ | ||
| ['PR', 'Current labels', 'New label', 'Action'].map(data => ({ data, header: true })), | ||
| [`<a href="https://github.com/${owner}/${repo}/pull/${pull_number}">#${pull_number}</a>`, | ||
| current.join(', ') || 'none', state || 'none', action], | ||
| ]).write(); | ||