Repository navigation
Report abstract-method-call for abstract methods called via super()
#1747
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" | |
| # Runs automation based on the current PR state: | |
| # - Labels the PR's size. | |
| # - Updates "waiting-for-*" labels to indicate who should act next. | |
| # - Checks ready PRs from outside the team against the pull request checklist | |
| # in CONTRIBUTING.md. PRs that fail are kept in "waiting-for-author", and | |
| # problems are reported in a single comment that is edited in place. | |
| on: | |
| pull_request_target: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - edited | |
| - 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: | |
| - Update state 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 without applying)' | |
| type: boolean | |
| default: true | |
| permissions: {} | |
| env: | |
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | |
| jobs: | |
| size: | |
| if: >- | |
| ${{ github.event_name == 'pull_request_target' | |
| && contains(fromJSON('["opened", "synchronize", "reopened"]'), github.event.action) }} | |
| # A called workflow can only narrow the permissions it inherits. | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| uses: ./.github/workflows/automation_label_pr_size.yml | |
| 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'))))) | |
| && (github.event.action != 'edited' || github.event.changes.body != null) }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read # find the last successful sweep | |
| contents: read # read owners.json once for the run | |
| issues: read # find issues updated since the last sweep | |
| pull-requests: read | |
| outputs: | |
| prs: ${{ steps.select.outputs.result }} | |
| owners: ${{ steps.select.outputs.owners }} | |
| since: ${{ steps.select.outputs.since }} | |
| 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 on one PR') { | |
| if (!pr_number) throw new Error('pr_number is required to update one PR'); | |
| return [Number(pr_number)]; | |
| } | |
| if (!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); | |
| // Without a previous sweep, don't enroll PRs from historical CLA events. | |
| core.setOutput('since', Number.isFinite(since) ? since : Date.now()); | |
| } | |
| const prs = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: 'open', per_page: 100, | |
| }); | |
| if (sweep) { | |
| // Claiming an issue doesn't update the PR, so also select PRs | |
| // whose authors are assigned to issues updated since the last | |
| // sweep. Without a last sweep, every open PR is selected anyway. | |
| const assignees = new Set(); | |
| if (Number.isFinite(since)) { | |
| for (const issue of await github.paginate(github.rest.issues.listForRepo, { | |
| owner, repo, state: 'open', since: new Date(since).toISOString(), per_page: 100, | |
| })) { | |
| if (!issue.pull_request) issue.assignees.forEach(a => assignees.add(a.login.toLowerCase())); | |
| } | |
| } | |
| // A PR opened during pagination can list another PR on two pages. | |
| return [...new Set(prs.filter(pr => (Date.parse(pr.updated_at) >= since | |
| || assignees.has(pr.user.login.toLowerCase())) | |
| && pr.user.type !== 'Bot' | |
| && !pr.head.ref.startsWith('export-D') | |
| && !owners.includes(pr.user.login.toLowerCase())) | |
| .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: | |
| # Waits for the size job so that the checklist sees the current size label. | |
| needs: [select-prs, size] | |
| if: ${{ !cancelled() && needs.select-prs.result == 'success' && 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, update labels, and comment | |
| issues: read # read issues referenced in the PR description | |
| checks: read # distinguish unsigned CLAs from pending checks | |
| steps: | |
| # The next step reacts to this step's result: "passed", "failed", or | |
| # "not run". The "changed" output is true when the result differs from | |
| # the one previously reported in the checklist comment, which makes the | |
| # next step re-evaluate the state as it does for a lifecycle event. The | |
| # "summary" output describes the result and the comment for the run | |
| # summary. | |
| - name: Check the PR against the pull request checklist | |
| id: checklist | |
| uses: actions/github-script@v8 | |
| env: | |
| PR_NUMBER: ${{ matrix.pr }} | |
| OWNERS_JSON: ${{ needs.select-prs.outputs.owners }} | |
| SWEEP_SINCE: ${{ needs.select-prs.outputs.since }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| result-encoding: string | |
| 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 MARKER = '<!-- pr-check -->'; | |
| // The "full" format adds `body_html`, the rendered description. | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, repo, pull_number, mediaType: { format: 'full' }, | |
| }); | |
| const owners = JSON.parse(process.env.OWNERS_JSON); | |
| const author = pr.user.login.toLowerCase(); | |
| const labels = (await github.paginate(github.rest.issues.listLabelsOnIssue, { | |
| owner, repo, issue_number, per_page: 100, | |
| })).map(l => l.name); | |
| const waitingForMerge = labels.includes('waiting-for-merge'); | |
| if (pr.user.type === 'Bot' || pr.head.ref.startsWith('export-D') || owners.includes(author) | |
| || pr.state !== 'open' || pr.draft | |
| || !labels.includes('cla signed')) { | |
| return 'not run'; | |
| } | |
| const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { | |
| owner, repo, issue_number, per_page: 100, | |
| }); | |
| const existingCheck = timeline.find(c => c.event === 'commented' && c.user?.login === 'github-actions[bot]' | |
| && c.user?.type === 'Bot' && c.body?.includes(MARKER)); | |
| // PRs waiting for merge only receive updates to an existing checklist comment. | |
| if (waitingForMerge && !existingCheck) { | |
| return 'not run'; | |
| } | |
| const sweep = context.eventName === 'schedule' | |
| || context.payload.inputs?.scenario === 'Reconcile PRs updated since last sweep'; | |
| const manual = context.eventName === 'workflow_dispatch' && !sweep; | |
| // "opened" runs wait for the size job, so they recheck PRs whose | |
| // "cla signed" label arrived before the size label. | |
| const initialCheck = (context.eventName === 'pull_request_target' | |
| && (['opened', 'ready_for_review'].includes(context.payload.action) | |
| || (context.payload.action === 'labeled' && context.payload.label.name === 'cla signed'))) | |
| || (sweep && timeline.some(e => (e.event === 'ready_for_review' | |
| || (e.event === 'labeled' && e.label?.name === 'cla signed')) | |
| && Date.parse(e.created_at) >= Number(process.env.SWEEP_SINCE))); | |
| if (!manual && !initialCheck && !existingCheck) { | |
| return 'not run'; | |
| } | |
| // "x" if the box is checked, " " if it is unchecked, or undefined if | |
| // it is missing. | |
| const box = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '') | |
| .match(/^\s*[-*]\s*\[([ x])\]\s*I am a human/im)?.[1].toLowerCase(); | |
| const problems = []; | |
| if (box !== 'x') { | |
| const policy = `See our [AI policy](https://github.com/${owner}/${repo}/blob/main/AI_POLICY.md).`; | |
| problems.push(box === undefined | |
| ? `Add this checkbox to the PR description and check it: \`- [x] I am a human, and I fully understand the code I am submitting.\` ${policy}` | |
| : `Check the "I am a human" box in the PR description. ${policy}`); | |
| } | |
| const CONTRIBUTING = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md`; | |
| // Issues referenced in the description are read from the links that | |
| // GitHub renders for them, so that GitHub decides what counts as a | |
| // reference. | |
| const issueNumbers = new Set([...(pr.body_html || '').matchAll( | |
| new RegExp(`data-hovercard-url="/${pr.base.repo.full_name}/issues/(\\d+)/hovercard"`, 'gi'))] | |
| .map(([, number]) => Number(number))); | |
| const referenced = []; | |
| for (const number of issueNumbers) { | |
| const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }); | |
| referenced.push(issue); | |
| } | |
| const issues = referenced.filter(i => i.state === 'open'); | |
| const CLAIMING = `${CONTRIBUTING}#claiming-issues-claim--unclaim`; | |
| const refs = list => list.map(i => `#${i.number}`).join(', '); | |
| const recommendations = []; | |
| if (issues.length === 0) { | |
| const allClosed = referenced.length > 0 | |
| ? `Every issue referenced in the PR description is already closed: ${refs(referenced)}. ` | |
| : ''; | |
| recommendations.push(`${allClosed}If this PR addresses an open issue, please link the issue in the PR description and [claim](${CLAIMING}) it.`); | |
| } else if (!issues.some(i => i.assignees.some(a => a.login.toLowerCase() === author))) { | |
| const unassigned = issues.filter(i => i.assignees.length === 0); | |
| // `#claim` refuses assigned issues, so when every issue is | |
| // assigned, the author is pointed to the reassignment process | |
| // instead. Assignees are not mentioned, to avoid notifying them. | |
| problems.push(unassigned.length > 0 | |
| ? `[Claim](${CLAIMING}) the issue that this PR addresses by commenting \`#claim\` on it. Unclaimed issues referenced in the PR description: ${refs(unassigned)}.` | |
| : `Every open issue referenced in the PR description is already assigned to someone else: ${refs(issues)}. If you're working on one of these issues, please [request](${CLAIMING}) that it be reassigned to you.`); | |
| } | |
| const split = `[splitting it](${CONTRIBUTING}#splitting-a-pull-request)`; | |
| if (labels.includes('size/xl')) { | |
| recommendations.push(`This PR is very large, which makes it slow and difficult to review. We strongly recommend ${split} into smaller PRs that can be merged independently. If that isn't possible, please structure it as a series of small commits. If you've already split it as far as is practical, no action is needed.`); | |
| } else if (labels.includes('size/l')) { | |
| recommendations.push(`This PR is on the larger side. If parts of it could be merged on their own, consider ${split} into separate PRs for faster review. If you've already split it as far as is practical, no action is needed.`); | |
| } | |
| const CHECKLIST = `[pull request checklist](${CONTRIBUTING}#making-a-pull-request)`; | |
| const list = items => items.map(item => `- ${item}`).join('\n'); | |
| const report = [ | |
| MARKER, | |
| problems.length > 0 | |
| ? `⚠️ **Action needed**: this PR is missing required items from our ${CHECKLIST}:\n\n${list(problems)}` | |
| : `✅ This PR meets all requirements from our ${CHECKLIST}.`, | |
| recommendations.length > 0 && `**Optional recommendations**:\n\n${list(recommendations)}`, | |
| 'Note: this comment updates automatically after changes to the PR. Updates may take up to half an hour.', | |
| ].filter(Boolean).join('\n\n'); | |
| const writeComment = existingCheck ? existingCheck.body !== report : problems.length + recommendations.length > 0; | |
| if (!dryRun && writeComment) { | |
| if (existingCheck) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existingCheck.id, body: report }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body: report }); | |
| } | |
| } | |
| const result = problems.length > 0 ? 'failed' : 'passed'; | |
| const comment = !writeComment ? (existingCheck ? 'comment unchanged' : 'no comment') | |
| : `comment ${dryRun ? 'would be ' : ''}${existingCheck ? 'updated' : 'posted'}`; | |
| core.setOutput('summary', `${result}, ${comment}`); | |
| // A PR without a checklist comment counts as passed, because no | |
| // problem has been reported to the author. | |
| const previous = existingCheck?.body.includes('⚠️ **Action needed**') ? 'failed' : 'passed'; | |
| console.log(`#${pull_number}: checklist ${previous} -> ${result}, ${comment}`); | |
| // Comment updates on PRs waiting for merge do not affect state labels. | |
| if (waitingForMerge) { | |
| return 'not run'; | |
| } | |
| core.setOutput('changed', previous !== result); | |
| return result; | |
| - name: Label the PR | |
| uses: actions/github-script@v8 | |
| env: | |
| PR_NUMBER: ${{ matrix.pr }} | |
| OWNERS_JSON: ${{ needs.select-prs.outputs.owners }} | |
| SWEEP_SINCE: ${{ needs.select-prs.outputs.since }} | |
| CHECKLIST: ${{ steps.checklist.outputs.result }} | |
| CHECKLIST_CHANGED: ${{ steps.checklist.outputs.changed }} | |
| CHECKLIST_SUMMARY: ${{ steps.checklist.outputs.summary }} | |
| 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)); | |
| const sweep = context.eventName === 'schedule' | |
| || context.payload.inputs?.scenario === 'Reconcile PRs updated since last sweep'; | |
| const manual = context.eventName === 'workflow_dispatch' && !sweep; | |
| const checklist = process.env.CHECKLIST; | |
| 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))); | |
| const isChecklistComment = e => e.event === 'commented' && e.user?.login === 'github-actions[bot]' | |
| && e.user?.type === 'Bot' && e.body?.includes('<!-- pr-check -->'); | |
| // 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. The checklist comment is edited in place, so | |
| // its latest edit is its event. Only the first successful sweep | |
| // after an edit handles it, because later sweeps start after it. | |
| const newEvents = sweep | |
| ? (await listTimeline()).filter(e => isChecklistComment(e) | |
| ? Date.parse(e.updated_at) > Math.max(changedAt, Number(process.env.SWEEP_SINCE)) | |
| : Date.parse(e.created_at ?? e.submitted_at) > changedAt) | |
| : []; | |
| const checklistChanged = checklist !== 'not run' | |
| && (process.env.CHECKLIST_CHANGED === 'true' || newEvents.some(isChecklistComment)); | |
| if (sweep && newEvents.length === 0 && !checklistChanged) { | |
| console.log(`#${pull_number}: no events since the latest state label change, nothing to do`); | |
| 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')) { | |
| console.log(`#${pull_number}: CLA bot comment but CLA is already signed, nothing to do`); | |
| return; | |
| } | |
| if ((pr.state !== 'open' || pr.draft) && !lifecycle && !manual) { | |
| console.log(`#${pull_number}: closed or draft PR, nothing to do`); | |
| 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)) { | |
| console.log(`#${pull_number}: inconclusive CLA check, nothing to do`); | |
| 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) { | |
| console.log(`#${pull_number}: issue comment is not a review request, nothing to do`); | |
| return; | |
| } | |
| const timeline = context.eventName !== 'workflow_run' && !sweep | |
| && ((approved && !changesRequested) || !latestFeedback) | |
| ? [] | |
| : await listTimeline(); | |
| // Review requests from bots, such as the nightly assignment | |
| // sweep, are not the author handing the PR back. | |
| const lastRerequest = Math.max( | |
| authorMention ? Date.parse(context.payload.comment.created_at) : -Infinity, | |
| ...timeline.filter(e => (e.event === 'review_requested' && e.actor?.type !== 'Bot') | |
| || (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') { | |
| console.log(`#${pull_number}: review request cannot approve or undo waiting-for-merge, leaving as-is`); | |
| return; | |
| } | |
| } else if (!checklistChanged | |
| && (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) { | |
| console.log(`#${pull_number}: no reviews, dismissals, or re-requests since the latest state label change, nothing to do`); | |
| return; | |
| } | |
| if (state === 'waiting-for-merge' | |
| && !newReviews.some(r => r.state === 'APPROVED') && !dismissed) { | |
| console.log(`#${pull_number}: no approval since the latest state label change, leaving as-is`); | |
| return; | |
| } | |
| if (state === 'waiting-for-author' && current.includes('waiting-for-merge') | |
| && !newReviews.some(r => r.state === 'CHANGES_REQUESTED') && !dismissed) { | |
| console.log(`#${pull_number}: no change request to undo waiting-for-merge, leaving as-is`); | |
| return; | |
| } | |
| // A re-request alone cannot undo waiting-for-merge. | |
| if (current.includes('waiting-for-merge') && newReviews.length === 0 && !dismissed) { | |
| console.log(`#${pull_number}: re-request cannot undo waiting-for-merge, leaving as-is`); | |
| return; | |
| } | |
| } else if (!lifecycle && !claChanged && !checklistChanged && !manual) { | |
| console.log(`#${pull_number}: event does not affect the state, nothing to do`); | |
| return; | |
| } | |
| if (!claSigned || (checklist === 'failed' && state === 'waiting-for-reviewer')) { | |
| 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', 'Checklist'].map(data => ({ data, header: true })), | |
| [`<a href="https://github.com/${owner}/${repo}/pull/${pull_number}">#${pull_number}</a>`, | |
| current.join(', ') || 'none', state || 'none', process.env.CHECKLIST_SUMMARY || 'not run'], | |
| ]).write(); |