Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .github/scripts/no-response.js
Original file line number Diff line number Diff line change
@@ -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,
});
Comment thread
elliette marked this conversation as resolved.

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,
});
Comment thread
elliette marked this conversation as resolved.

const labelEvent = events.reverse().find(
event =>
event.event === 'labeled' &&
event.label?.name === labelName
);
Comment thread
elliette marked this conversation as resolved.

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,
});
Comment thread
elliette marked this conversation as resolved.

for (const comment of comments) {
if (pr.user?.id && comment.user?.id === pr.user.id && new Date(comment.created_at) > labeledAt) {
isResponse = true;
break;
}
}
Comment thread
elliette marked this conversation as resolved.

// 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,
});
Comment thread
elliette marked this conversation as resolved.

for (const commit of commits) {
const commitDate = new Date(commit.commit?.committer?.date || commit.commit?.author?.date);
if (commitDate > labeledAt) {
isResponse = true;
break;
}
}
Comment thread
elliette marked this conversation as resolved.
}

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',
});
}
}
}
};
34 changes: 34 additions & 0 deletions .github/workflows/no-response.yaml
Original file line number Diff line number Diff line change
@@ -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});
Loading