diff --git a/.github/scripts/no-response.js b/.github/scripts/no-response.js new file mode 100644 index 000000000000..023325e20a44 --- /dev/null +++ b/.github/scripts/no-response.js @@ -0,0 +1,128 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +module.exports = async ({ github, context }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const labelName = 'waiting for response'; + const daysUntilClose = 21; + + const prCloseComment = `This pull request is being closed because it has not been updated in the last 21 days after a request for more information. + +If you are still working on this, please feel free to reopen it or file a new pull request. + +Thanks for your contribution.`; + + const now = new Date(); + const closeDate = new Date(now.getTime() - (daysUntilClose * 24 * 60 * 60 * 1000)); + + // GitHub's pulls REST API does not support filtering by label. In GitHub's + // data model, pull requests are treated as issues, so we query the issues + // endpoint with the target label. Since issues are disabled in flutter/packages, + // this will only return pull requests. + const prs = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: labelName, + per_page: 100, + }); + + for (const pr of prs) { + if (!pr.pull_request) { + continue; + } + + // Fetch timeline events to find label date + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + + const labelEvent = events.reverse().find( + event => + event.event === 'labeled' && + event.label?.name === labelName + ); + + if (!labelEvent) { + continue; // Skip if label not found in events + } + + const labeledAt = new Date(labelEvent.created_at); + let isResponse = false; + + // Check for author comments after label + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + since: labeledAt.toISOString(), + per_page: 100, + }); + + for (const comment of comments) { + if (pr.user?.id && comment.user?.id === pr.user.id && new Date(comment.created_at) > labeledAt) { + isResponse = true; + break; + } + } + + // Check for commits after label + if (!isResponse) { + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + for (const commit of commits) { + const commitDate = new Date(commit.commit?.committer?.date || commit.commit?.author?.date); + if (commitDate > labeledAt) { + isResponse = true; + break; + } + } + } + + if (isResponse) { + console.log(`Removing label from #${pr.number} as author responded.`); + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: labelName, + }); + + continue; // Skip stale check + } + + // Stale check + if (pr.state === 'open') { + if (labeledAt < closeDate) { + if (pr.locked) { + console.log(`Skipping #${pr.number} because the conversation is locked.`); + continue; + } + + console.log(`Closing #${pr.number} due to no response.`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: prCloseComment, + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: pr.number, + state: 'closed', + }); + } + } + } +}; diff --git a/.github/workflows/no-response.yaml b/.github/workflows/no-response.yaml new file mode 100644 index 000000000000..f886b165c31c --- /dev/null +++ b/.github/workflows/no-response.yaml @@ -0,0 +1,34 @@ +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +name: No Response + +on: + schedule: + # Schedule for every 30 minutes. + - cron: '*/30 * * * *' + workflow_dispatch: + +# By specifying the access of one of the scopes, all of those that are not +# specified are set to 'none'. +permissions: + issues: write + pull-requests: write + +jobs: + noResponse: + runs-on: ubuntu-latest + if: ${{ github.repository == 'flutter/packages' }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: No Response + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const script = require('./.github/scripts/no-response.js'); + await script({github, context});