From 460a237860bff574934535229ecf1d5578119b7e Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sat, 31 Oct 2026 22:07:40 +0530 Subject: [PATCH 1/9] Testing --- .github/workflows/unassign_bot.js | 0 .github/workflows/unassign_bot.yml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/unassign_bot.js create mode 100644 .github/workflows/unassign_bot.yml diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/.github/workflows/unassign_bot.yml b/.github/workflows/unassign_bot.yml new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/.github/workflows/unassign_bot.yml @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. From f4dfc7fdcb788d78c142c5dfd24540ab9f35a6e6 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sat, 31 Oct 2026 23:08:59 +0530 Subject: [PATCH 2/9] Test on fork --- .github/workflows/unassign_bot.js | 221 +++++++++++++++++++++++++++++ .github/workflows/unassign_bot.yml | 81 +++++++++++ 2 files changed, 302 insertions(+) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index e69de29bb2d1..5bc0d059349e 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -0,0 +1,221 @@ + +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Releases abandoned assignments on newcomer-friendly issues: +// - a linked PR is closed without merging -> unassign immediately +// - no linked PR and no assignee comment for WARN_AFTER_DAYS -> warn, +// then unassign GRACE_DAYS later if there is still no activity. + +const helpers = require("./dev_pr/helpers.js"); + +const WARN_AFTER_DAYS = 5 / (24 * 60); // 5 minutes +const GRACE_DAYS = 1 / (24 * 60); // 1 minute +const LABELS = ["good-first-issue", "good-second-issue"]; +const DAY = 24 * 60 * 60 * 1000; + +const same = (a, b) => a?.toLowerCase() === b?.toLowerCase(); +const hasNewcomerLabel = issue => + issue.labels.some(label => LABELS.includes(label.name)); +const warningMarker = login => + ``; + +// Issue number from Arrow's "GH-1234: ..." PR title convention. +function linkedIssue(pullRequest) { + const ref = helpers.detectIssue(pullRequest.title); + return ref?.kind === "github" ? Number(ref.id) : null; +} + +module.exports = async ({ github, context }) => { + const { owner, repo } = context.repo; + const paginate = (method, params) => + github.paginate(method, { owner, repo, per_page: 100, ...params }); + + async function comment(number, body) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: number, + body, + }); + } + + async function unassign(number, login, why) { + console.log(`Unassigning ${login} from #${number}: ${why}`); + + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: number, + assignees: [login], + }); + + await comment( + number, + `This issue was automatically unassigned from @${login} because ${why}.\n\n` + + `If you are still working on this issue, please leave a comment here to let us know. ` + + `A maintainer or collaborator can reassign it to you.\n\n` + + `Other contributors who are interested in working on this issue are welcome to pick it up.` + ); + } + + // A linked PR was closed without merging. + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + const number = linkedIssue(pr); + + if (context.payload.action !== "closed" || pr.merged || !number) { + return; + } + + const issue = await github.rest.issues + .get({ owner, repo, issue_number: number }) + .then(r => r.data, () => null); // the title may name a missing issue + + if (!issue) { + console.log(`Skipping PR #${pr.number}: referenced issue #${number} was not found`); + return; + } + + const author = pr.user.login; + + if ( + !hasNewcomerLabel(issue) || + !issue.assignees.some(a => same(a.login, author)) + ) { + return; + } + + // Another PR protects its own author, not this contributor. + const open = await paginate(github.rest.pulls.list, { state: "open" }); + + if ( + open.some( + p => + p.number !== pr.number && + linkedIssue(p) === number && + same(p.user.login, author) + ) + ) { + return; + } + + await unassign( + number, + author, + `PR #${pr.number} was closed without being merged` + ); + return; + } + + // Scheduled / manual: warn, then release assignments with no author PR. + const byNumber = new Map(); + + for (const label of LABELS) { + const issues = await paginate(github.rest.issues.listForRepo, { + state: "open", + labels: label, + }); + + for (const issue of issues) { + if (!issue.pull_request && issue.assignees.length) { + byNumber.set(issue.number, issue); + } + } + } + + const openPrs = await paginate(github.rest.pulls.list, { state: "open" }); + + for (const issue of byNumber.values()) { + for (const { login } of issue.assignees) { + try { + // An open linked PR protects only its own author. + const hasOpenPr = openPrs.some( + pr => + linkedIssue(pr) === issue.number && + same(pr.user.login, login) + ); + + if (hasOpenPr) { + continue; + } + + const events = await paginate(github.rest.issues.listEvents, { + issue_number: issue.number, + }); + + const assignedAt = Math.max( + 0, + ...events + .filter( + event => + event.event === "assigned" && + same(event.assignee?.login, login) + ) + .map(event => Date.parse(event.created_at)) + ); + + if (!assignedAt) { + continue; // unknown assignment time: keep the assignment + } + + const comments = await paginate(github.rest.issues.listComments, { + issue_number: issue.number, + since: new Date(assignedAt).toISOString(), + }); + + const lastActivity = Math.max( + assignedAt, + ...comments + .filter(comment => same(comment.user?.login, login)) + .map(comment => Date.parse(comment.created_at)) + ); + + if (Date.now() - lastActivity < WARN_AFTER_DAYS * DAY) { + continue; + } + + const warning = comments.find( + comment => + comment.body.includes(warningMarker(login)) && + Date.parse(comment.created_at) >= lastActivity + ); + + if (!warning) { + console.log(`Warning ${login} on #${issue.number}`); + + await comment( + issue.number, + `${warningMarker(login)}\n@${login}, there has been no linked pull request ` + + `or comment from you on this issue for ${WARN_AFTER_DAYS} days. It will be ` + + `unassigned in ${GRACE_DAYS} days unless you comment here or open a PR.` + ); + } else if ( + Date.now() - Date.parse(warning.created_at) >= GRACE_DAYS * DAY + ) { + await unassign( + issue.number, + login, + `there was no linked PR or activity for ${WARN_AFTER_DAYS + GRACE_DAYS} days` + ); + } + } catch (error) { + console.log(`Failed on #${issue.number} (${login}): ${error.message}`); + } + } + } +}; diff --git a/.github/workflows/unassign_bot.yml b/.github/workflows/unassign_bot.yml index 13a83393a912..ab554dd9041a 100644 --- a/.github/workflows/unassign_bot.yml +++ b/.github/workflows/unassign_bot.yml @@ -14,3 +14,84 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +name: Unassign Inactive Contributors + +on: + # Time-based expiry: the only place where inactivity is evaluated. + schedule: + - cron: "0 10 * * *" + # Manual run. Defaults to a dry run that only logs what would happen. + workflow_dispatch: + inputs: + dry_run: + description: "Only log what would be unassigned, change nothing" + type: boolean + default: true + # Immediate handling when a linked PR is closed without merging. + # pull_request_target is required so that the token can write to the + # issue when the PR comes from a fork. No PR code is checked out or run. + pull_request_target: + types: + - closed + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Unassign Inactive Contributors + +on: + schedule: + - cron: "0 10 * * *" + workflow_dispatch: + pull_request_target: + types: + - closed + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: unassign-inactive-contributors + cancel-in-progress: false + +jobs: + unassign-inactive-contributors: + name: Unassign inactive contributors + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout Arrow + uses: actions/checkout@v7 + with: + persist-credentials: false + sparse-checkout: .github + + - name: Run unassign bot + uses: actions/github-script@v9 + with: + script: | + const unassignBot = require("./.github/workflows/unassign_bot.js"); + await unassignBot({ github, context, core }); From dbeae600af2c118f5924b68cf66b7f5a80bc4527 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 00:53:41 +0530 Subject: [PATCH 3/9] Auto-unassign feature in newcomer issues --- .github/workflows/unassign_bot.js | 173 ++++++++++++++++++++++++----- .github/workflows/unassign_bot.yml | 46 +------- 2 files changed, 148 insertions(+), 71 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index 5bc0d059349e..46e7d1fbb7c1 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -1,4 +1,3 @@ - // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -8,7 +7,6 @@ // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 -// // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -17,20 +15,39 @@ // under the License. // Releases abandoned assignments on newcomer-friendly issues: -// - a linked PR is closed without merging -> unassign immediately -// - no linked PR and no assignee comment for WARN_AFTER_DAYS -> warn, +// - a linked PR is closed without merging and no other open linked PR +// exists from the same author -> unassign immediately +// - no qualifying activity for WARN_AFTER_DAYS -> warn, // then unassign GRACE_DAYS later if there is still no activity. const helpers = require("./dev_pr/helpers.js"); -const WARN_AFTER_DAYS = 5 / (24 * 60); // 5 minutes -const GRACE_DAYS = 1 / (24 * 60); // 1 minute +const WARN_AFTER_DAYS = 5; +const GRACE_DAYS = 3; const LABELS = ["good-first-issue", "good-second-issue"]; const DAY = 24 * 60 * 60 * 1000; +const TIMER_PAUSE_LABELS = [ + "awaiting review", + "awaiting committer review", + "awaiting change review", + "awaiting merge", +]; + +// GitHub author associations used to identify maintainers and collaborators. +const isMaintainer = association => + ["MEMBER", "OWNER", "COLLABORATOR"].includes(association); + +// A PR awaiting review or merge is not considered inactive. +const hasTimerPauseLabel = pr => + pr.labels.some(label => + TIMER_PAUSE_LABELS.includes(label.name.toLowerCase()) + ); const same = (a, b) => a?.toLowerCase() === b?.toLowerCase(); + const hasNewcomerLabel = issue => issue.labels.some(label => LABELS.includes(label.name)); + const warningMarker = login => ``; @@ -82,12 +99,23 @@ module.exports = async ({ github, context }) => { return; } - const issue = await github.rest.issues - .get({ owner, repo, issue_number: number }) - .then(r => r.data, () => null); // the title may name a missing issue + let issue; + try { + issue = ( + await github.rest.issues.get({ + owner, + repo, + issue_number: number, + }) + ).data; + } catch (error) { + if (error.status !== 404) { + throw error; + } - if (!issue) { - console.log(`Skipping PR #${pr.number}: referenced issue #${number} was not found`); + console.log( + `Skipping PR #${pr.number}: referenced issue #${number} was not found` + ); return; } @@ -122,7 +150,7 @@ module.exports = async ({ github, context }) => { return; } - // Scheduled / manual: warn, then release assignments with no author PR. + // Scheduled / manual: warn, then release inactive assignments. const byNumber = new Map(); for (const label of LABELS) { @@ -138,19 +166,23 @@ module.exports = async ({ github, context }) => { } } + // Fetch open PRs once and reuse them for all assigned issues. const openPrs = await paginate(github.rest.pulls.list, { state: "open" }); + let failures = 0; + for (const issue of byNumber.values()) { for (const { login } of issue.assignees) { try { - // An open linked PR protects only its own author. - const hasOpenPr = openPrs.some( + // Only consider linked PRs opened by this assignee. + const linkedPrs = openPrs.filter( pr => linkedIssue(pr) === issue.number && same(pr.user.login, login) ); - if (hasOpenPr) { + // Pause the timer while the PR is awaiting review. + if (linkedPrs.some(hasTimerPauseLabel)) { continue; } @@ -158,6 +190,8 @@ module.exports = async ({ github, context }) => { issue_number: issue.number, }); + // Use the most recent assignment event to avoid counting activity from + // before the contributor was assigned. const assignedAt = Math.max( 0, ...events @@ -170,7 +204,7 @@ module.exports = async ({ github, context }) => { ); if (!assignedAt) { - continue; // unknown assignment time: keep the assignment + continue; // Unknown assignment time: keep the assignment. } const comments = await paginate(github.rest.issues.listComments, { @@ -178,11 +212,94 @@ module.exports = async ({ github, context }) => { since: new Date(assignedAt).toISOString(), }); + // Activity on a linked PR counts when it comes from the assignee or + // someone with maintainer/collaborator permissions. + const prActivity = []; + + for (const pr of linkedPrs) { + const [prComments, reviewComments, reviews, labelEvents, commits] = + await Promise.all([ + paginate(github.rest.issues.listComments, { + issue_number: pr.number, + since: new Date(assignedAt).toISOString(), + }), + paginate(github.rest.pulls.listReviewComments, { + pull_number: pr.number, + since: new Date(assignedAt).toISOString(), + }), + paginate(github.rest.pulls.listReviews, { + pull_number: pr.number, + }), + paginate(github.rest.issues.listEvents, { + issue_number: pr.number, + }), + paginate(github.rest.pulls.listCommits, { + pull_number: pr.number, + }), + ]); + + prActivity.push( + ...prComments + .filter( + c => + Date.parse(c.created_at) >= assignedAt && + (same(c.user?.login, login) || + isMaintainer(c.author_association)) + ) + .map(c => Date.parse(c.created_at)), + + ...reviewComments + .filter( + c => + Date.parse(c.created_at) >= assignedAt && + (same(c.user?.login, login) || + isMaintainer(c.author_association)) + ) + .map(c => Date.parse(c.created_at)), + + ...reviews + .filter( + r => + r.submitted_at && + Date.parse(r.submitted_at) >= assignedAt && + (same(r.user?.login, login) || + isMaintainer(r.author_association)) + ) + .map(r => Date.parse(r.submitted_at)), + + // Removing a timer-pausing label starts a fresh countdown. + ...labelEvents + .filter( + event => + event.event === "unlabeled" && + TIMER_PAUSE_LABELS.includes( + event.label?.name?.toLowerCase() + ) && + Date.parse(event.created_at) >= assignedAt + ) + .map(event => Date.parse(event.created_at)), + + ...commits + .filter( + commit => + Date.parse(commit.commit.committer.date) >= assignedAt && + same(commit.committer?.login, login) + ) + .map(commit => Date.parse(commit.commit.committer.date)) + ); + } + + // The latest qualifying activity determines when the inactivity period starts. const lastActivity = Math.max( assignedAt, ...comments - .filter(comment => same(comment.user?.login, login)) - .map(comment => Date.parse(comment.created_at)) + .filter( + c => + Date.parse(c.created_at) >= assignedAt && + same(c.user?.login, login) + ) + .map(c => Date.parse(c.created_at)), + ...prActivity ); if (Date.now() - lastActivity < WARN_AFTER_DAYS * DAY) { @@ -190,19 +307,17 @@ module.exports = async ({ github, context }) => { } const warning = comments.find( - comment => - comment.body.includes(warningMarker(login)) && - Date.parse(comment.created_at) >= lastActivity + c => + c.body.includes(warningMarker(login)) && + Date.parse(c.created_at) >= lastActivity ); if (!warning) { - console.log(`Warning ${login} on #${issue.number}`); - await comment( issue.number, - `${warningMarker(login)}\n@${login}, there has been no linked pull request ` + - `or comment from you on this issue for ${WARN_AFTER_DAYS} days. It will be ` + - `unassigned in ${GRACE_DAYS} days unless you comment here or open a PR.` + `${warningMarker(login)}\n@${login}, there has been no qualifying activity ` + + `on this issue or its linked PR for ${WARN_AFTER_DAYS} days. It will be ` + + `unassigned in ${GRACE_DAYS} days unless there is further activity.` ); } else if ( Date.now() - Date.parse(warning.created_at) >= GRACE_DAYS * DAY @@ -214,8 +329,12 @@ module.exports = async ({ github, context }) => { ); } } catch (error) { - console.log(`Failed on #${issue.number} (${login}): ${error.message}`); + failures++; + console.error(`Failed on #${issue.number} (${login})`, error); } } } + if (failures > 0) { + throw new Error(`Failed to process ${failures} assignment(s).`); + } }; diff --git a/.github/workflows/unassign_bot.yml b/.github/workflows/unassign_bot.yml index ab554dd9041a..0af0757a699b 100644 --- a/.github/workflows/unassign_bot.yml +++ b/.github/workflows/unassign_bot.yml @@ -17,49 +17,6 @@ name: Unassign Inactive Contributors -on: - # Time-based expiry: the only place where inactivity is evaluated. - schedule: - - cron: "0 10 * * *" - # Manual run. Defaults to a dry run that only logs what would happen. - workflow_dispatch: - inputs: - dry_run: - description: "Only log what would be unassigned, change nothing" - type: boolean - default: true - # Immediate handling when a linked PR is closed without merging. - # pull_request_target is required so that the token can write to the - # issue when the PR comes from a fork. No PR code is checked out or run. - pull_request_target: - types: - - closed - -permissions: - contents: read - issues: write - pull-requests: read - -concurrency: -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -name: Unassign Inactive Contributors - on: schedule: - cron: "0 10 * * *" @@ -75,6 +32,7 @@ permissions: concurrency: group: unassign-inactive-contributors + queue: max cancel-in-progress: false jobs: @@ -94,4 +52,4 @@ jobs: with: script: | const unassignBot = require("./.github/workflows/unassign_bot.js"); - await unassignBot({ github, context, core }); + await unassignBot({ github, context}); From acbf0ffdc29562e5babaf962b00fa8e7bdda68b3 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 19:36:13 +0530 Subject: [PATCH 4/9] Address feedback --- .github/workflows/unassign_bot.js | 158 ++++++++--------------------- .github/workflows/unassign_bot.yml | 11 +- 2 files changed, 43 insertions(+), 126 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index 46e7d1fbb7c1..5d8da9fdf20b 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -15,8 +15,6 @@ // under the License. // Releases abandoned assignments on newcomer-friendly issues: -// - a linked PR is closed without merging and no other open linked PR -// exists from the same author -> unassign immediately // - no qualifying activity for WARN_AFTER_DAYS -> warn, // then unassign GRACE_DAYS later if there is still no activity. @@ -45,9 +43,6 @@ const hasTimerPauseLabel = pr => const same = (a, b) => a?.toLowerCase() === b?.toLowerCase(); -const hasNewcomerLabel = issue => - issue.labels.some(label => LABELS.includes(label.name)); - const warningMarker = login => ``; @@ -90,67 +85,7 @@ module.exports = async ({ github, context }) => { ); } - // A linked PR was closed without merging. - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - const number = linkedIssue(pr); - - if (context.payload.action !== "closed" || pr.merged || !number) { - return; - } - - let issue; - try { - issue = ( - await github.rest.issues.get({ - owner, - repo, - issue_number: number, - }) - ).data; - } catch (error) { - if (error.status !== 404) { - throw error; - } - - console.log( - `Skipping PR #${pr.number}: referenced issue #${number} was not found` - ); - return; - } - - const author = pr.user.login; - - if ( - !hasNewcomerLabel(issue) || - !issue.assignees.some(a => same(a.login, author)) - ) { - return; - } - - // Another PR protects its own author, not this contributor. - const open = await paginate(github.rest.pulls.list, { state: "open" }); - - if ( - open.some( - p => - p.number !== pr.number && - linkedIssue(p) === number && - same(p.user.login, author) - ) - ) { - return; - } - - await unassign( - number, - author, - `PR #${pr.number} was closed without being merged` - ); - return; - } - - // Scheduled / manual: warn, then release inactive assignments. + // Scheduled: warn, then release inactive assignments. const byNumber = new Map(); for (const label of LABELS) { @@ -207,25 +142,33 @@ module.exports = async ({ github, context }) => { continue; // Unknown assignment time: keep the assignment. } + const since = new Date(assignedAt).toISOString(); const comments = await paginate(github.rest.issues.listComments, { issue_number: issue.number, - since: new Date(assignedAt).toISOString(), + since, }); // Activity on a linked PR counts when it comes from the assignee or // someone with maintainer/collaborator permissions. const prActivity = []; + const activityDates = (items, getDate, qualifies = () => true) => + items + .filter(item => { + const timestamp = Date.parse(getDate(item)); + return timestamp >= assignedAt && qualifies(item); + }) + .map(item => Date.parse(getDate(item))); for (const pr of linkedPrs) { const [prComments, reviewComments, reviews, labelEvents, commits] = await Promise.all([ paginate(github.rest.issues.listComments, { issue_number: pr.number, - since: new Date(assignedAt).toISOString(), + since, }), paginate(github.rest.pulls.listReviewComments, { pull_number: pr.number, - since: new Date(assignedAt).toISOString(), + since, }), paginate(github.rest.pulls.listReviews, { pull_number: pr.number, @@ -239,53 +182,36 @@ module.exports = async ({ github, context }) => { ]); prActivity.push( - ...prComments - .filter( - c => - Date.parse(c.created_at) >= assignedAt && - (same(c.user?.login, login) || - isMaintainer(c.author_association)) - ) - .map(c => Date.parse(c.created_at)), - - ...reviewComments - .filter( - c => - Date.parse(c.created_at) >= assignedAt && - (same(c.user?.login, login) || - isMaintainer(c.author_association)) - ) - .map(c => Date.parse(c.created_at)), - - ...reviews - .filter( - r => - r.submitted_at && - Date.parse(r.submitted_at) >= assignedAt && - (same(r.user?.login, login) || - isMaintainer(r.author_association)) - ) - .map(r => Date.parse(r.submitted_at)), + ...activityDates( + [...prComments, ...reviewComments], + c => c.created_at, + c => + same(c.user?.login, login) || + isMaintainer(c.author_association) + ), + + ...activityDates( + reviews.filter(review => review.submitted_at), + review => review.submitted_at, + review => + same(review.user?.login, login) || + isMaintainer(review.author_association) + ), // Removing a timer-pausing label starts a fresh countdown. - ...labelEvents - .filter( - event => - event.event === "unlabeled" && - TIMER_PAUSE_LABELS.includes( - event.label?.name?.toLowerCase() - ) && - Date.parse(event.created_at) >= assignedAt - ) - .map(event => Date.parse(event.created_at)), - - ...commits - .filter( - commit => - Date.parse(commit.commit.committer.date) >= assignedAt && - same(commit.committer?.login, login) - ) - .map(commit => Date.parse(commit.commit.committer.date)) + ...activityDates( + labelEvents, + event => event.created_at, + event => + event.event === "unlabeled" && + TIMER_PAUSE_LABELS.includes(event.label?.name?.toLowerCase()) + ), + + ...activityDates( + commits, + commit => commit.commit.committer.date, + commit => same(commit.committer?.login, login) + ) ); } @@ -316,8 +242,8 @@ module.exports = async ({ github, context }) => { await comment( issue.number, `${warningMarker(login)}\n@${login}, there has been no qualifying activity ` + - `on this issue or its linked PR for ${WARN_AFTER_DAYS} days. It will be ` + - `unassigned in ${GRACE_DAYS} days unless there is further activity.` + `on this issue or its linked PR for ${WARN_AFTER_DAYS} days. It will be ` + + `unassigned in ${GRACE_DAYS} days unless there is further activity.` ); } else if ( Date.now() - Date.parse(warning.created_at) >= GRACE_DAYS * DAY diff --git a/.github/workflows/unassign_bot.yml b/.github/workflows/unassign_bot.yml index 0af0757a699b..489a9559e574 100644 --- a/.github/workflows/unassign_bot.yml +++ b/.github/workflows/unassign_bot.yml @@ -20,21 +20,12 @@ name: Unassign Inactive Contributors on: schedule: - cron: "0 10 * * *" - workflow_dispatch: - pull_request_target: - types: - - closed permissions: contents: read issues: write pull-requests: read -concurrency: - group: unassign-inactive-contributors - queue: max - cancel-in-progress: false - jobs: unassign-inactive-contributors: name: Unassign inactive contributors @@ -52,4 +43,4 @@ jobs: with: script: | const unassignBot = require("./.github/workflows/unassign_bot.js"); - await unassignBot({ github, context}); + await unassignBot({ github, context }); From ec400626531aaf1976a31e4e32d095d5c681b565 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Mon, 2 Nov 2026 23:35:53 +0530 Subject: [PATCH 5/9] Simplified Code --- .github/workflows/unassign_bot.js | 327 ++++++++++++------------------ 1 file changed, 134 insertions(+), 193 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index 5d8da9fdf20b..f6b486cff14e 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -7,6 +7,7 @@ // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 +// // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -14,80 +15,53 @@ // specific language governing permissions and limitations // under the License. -// Releases abandoned assignments on newcomer-friendly issues: -// - no qualifying activity for WARN_AFTER_DAYS -> warn, -// then unassign GRACE_DAYS later if there is still no activity. - const helpers = require("./dev_pr/helpers.js"); -const WARN_AFTER_DAYS = 5; -const GRACE_DAYS = 3; const LABELS = ["good-first-issue", "good-second-issue"]; -const DAY = 24 * 60 * 60 * 1000; -const TIMER_PAUSE_LABELS = [ +const PAUSE_LABELS = [ "awaiting review", "awaiting committer review", "awaiting change review", "awaiting merge", ]; +const DAY = 24 * 60 * 60 * 1000; +const WARN_AFTER = 5 * DAY; +const GRACE_PERIOD = 3 * DAY; -// GitHub author associations used to identify maintainers and collaborators. +const sameUser = (a, b) => + Boolean(a && b && a.toLowerCase() === b.toLowerCase()); const isMaintainer = association => ["MEMBER", "OWNER", "COLLABORATOR"].includes(association); - -// A PR awaiting review or merge is not considered inactive. -const hasTimerPauseLabel = pr => - pr.labels.some(label => - TIMER_PAUSE_LABELS.includes(label.name.toLowerCase()) - ); - -const same = (a, b) => a?.toLowerCase() === b?.toLowerCase(); - const warningMarker = login => ``; -// Issue number from Arrow's "GH-1234: ..." PR title convention. -function linkedIssue(pullRequest) { - const ref = helpers.detectIssue(pullRequest.title); - return ref?.kind === "github" ? Number(ref.id) : null; -} - module.exports = async ({ github, context }) => { const { owner, repo } = context.repo; const paginate = (method, params) => github.paginate(method, { owner, repo, per_page: 100, ...params }); + const comment = (issue_number, body) => + github.rest.issues.createComment({ owner, repo, issue_number, body }); - async function comment(number, body) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: number, - body, - }); - } - - async function unassign(number, login, why) { - console.log(`Unassigning ${login} from #${number}: ${why}`); - + const unassign = async (issue_number, login) => { + console.log(`Unassigning ${login} from #${issue_number}`); await github.rest.issues.removeAssignees({ owner, repo, - issue_number: number, + issue_number, assignees: [login], }); - await comment( - number, - `This issue was automatically unassigned from @${login} because ${why}.\n\n` + - `If you are still working on this issue, please leave a comment here to let us know. ` + - `A maintainer or collaborator can reassign it to you.\n\n` + - `Other contributors who are interested in working on this issue are welcome to pick it up.` + issue_number, + `This issue was automatically unassigned from @${login} due to inactivity. ` + + `If you are still working on it, please comment here so a maintainer can reassign it.` ); - } + }; - // Scheduled: warn, then release inactive assignments. - const byNumber = new Map(); + const openPrs = await paginate(github.rest.pulls.list, { state: "open" }); + const processed = new Set(); + let failures = 0; + // Process each issue only once, even when it has both newcomer labels. for (const label of LABELS) { const issues = await paginate(github.rest.issues.listForRepo, { state: "open", @@ -95,172 +69,139 @@ module.exports = async ({ github, context }) => { }); for (const issue of issues) { - if (!issue.pull_request && issue.assignees.length) { - byNumber.set(issue.number, issue); + if (issue.pull_request || !issue.assignees.length || processed.has(issue.number)) { + continue; } - } - } - - // Fetch open PRs once and reuse them for all assigned issues. - const openPrs = await paginate(github.rest.pulls.list, { state: "open" }); - - let failures = 0; - - for (const issue of byNumber.values()) { - for (const { login } of issue.assignees) { - try { - // Only consider linked PRs opened by this assignee. - const linkedPrs = openPrs.filter( - pr => - linkedIssue(pr) === issue.number && - same(pr.user.login, login) - ); - - // Pause the timer while the PR is awaiting review. - if (linkedPrs.some(hasTimerPauseLabel)) { - continue; - } - - const events = await paginate(github.rest.issues.listEvents, { - issue_number: issue.number, - }); - - // Use the most recent assignment event to avoid counting activity from - // before the contributor was assigned. - const assignedAt = Math.max( - 0, - ...events - .filter( - event => - event.event === "assigned" && - same(event.assignee?.login, login) + processed.add(issue.number); + + for (const { login } of issue.assignees) { + try { + const linkedPrs = openPrs.filter(pr => { + const ref = helpers.detectIssue(pr.title); + return ( + ref?.kind === "github" && + Number(ref.id) === issue.number && + sameUser(pr.user.login, login) + ); + }); + + // A linked PR awaiting review or merge pauses the inactivity timer. + if ( + linkedPrs.some(pr => + pr.labels.some(label => PAUSE_LABELS.includes(label.name.toLowerCase())) ) - .map(event => Date.parse(event.created_at)) - ); - - if (!assignedAt) { - continue; // Unknown assignment time: keep the assignment. - } - - const since = new Date(assignedAt).toISOString(); - const comments = await paginate(github.rest.issues.listComments, { - issue_number: issue.number, - since, - }); - - // Activity on a linked PR counts when it comes from the assignee or - // someone with maintainer/collaborator permissions. - const prActivity = []; - const activityDates = (items, getDate, qualifies = () => true) => - items - .filter(item => { - const timestamp = Date.parse(getDate(item)); - return timestamp >= assignedAt && qualifies(item); - }) - .map(item => Date.parse(getDate(item))); - - for (const pr of linkedPrs) { - const [prComments, reviewComments, reviews, labelEvents, commits] = - await Promise.all([ - paginate(github.rest.issues.listComments, { + ) { + continue; + } + + // Timeline events include assignment, comments, reviews and label changes. + const events = await paginate(github.rest.issues.listEventsForTimeline, { + issue_number: issue.number, + }); + const assignedAt = Math.max( + 0, + ...events + .filter( + event => + event.event === "assigned" && + sameUser(event.assignee?.login, login) + ) + .map(event => Date.parse(event.created_at)) + ); + if (!assignedAt) continue; + + // Start at the assignment time so activity before assignment is ignored. + let lastActivity = assignedAt; + for (const event of events) { + if (event.event === "commented" && sameUser(event.user?.login, login)) { + lastActivity = Math.max(lastActivity, Date.parse(event.created_at)); + } + } + + for (const pr of linkedPrs) { + const [prEvents, reviewComments, commits] = await Promise.all([ + paginate(github.rest.issues.listEventsForTimeline, { issue_number: pr.number, - since, }), paginate(github.rest.pulls.listReviewComments, { pull_number: pr.number, - since, - }), - paginate(github.rest.pulls.listReviews, { - pull_number: pr.number, - }), - paginate(github.rest.issues.listEvents, { - issue_number: pr.number, }), paginate(github.rest.pulls.listCommits, { pull_number: pr.number, }), ]); - prActivity.push( - ...activityDates( - [...prComments, ...reviewComments], - c => c.created_at, - c => - same(c.user?.login, login) || - isMaintainer(c.author_association) - ), + for (const event of prEvents) { + const isCommentOrReview = + ["commented", "reviewed"].includes(event.event) && + (sameUser(event.user?.login, login) || + isMaintainer(event.author_association)); - ...activityDates( - reviews.filter(review => review.submitted_at), - review => review.submitted_at, - review => - same(review.user?.login, login) || - isMaintainer(review.author_association) - ), - - // Removing a timer-pausing label starts a fresh countdown. - ...activityDates( - labelEvents, - event => event.created_at, - event => + const isPauseLabelRemoved = event.event === "unlabeled" && - TIMER_PAUSE_LABELS.includes(event.label?.name?.toLowerCase()) - ), - - ...activityDates( - commits, - commit => commit.commit.committer.date, - commit => same(commit.committer?.login, login) - ) + PAUSE_LABELS.includes(event.label?.name?.toLowerCase()); + + if (isCommentOrReview || isPauseLabelRemoved) { + lastActivity = Math.max( + lastActivity, + Date.parse(event.submitted_at || event.created_at) + ); + } + } + + // Inline review comments are not included in the PR timeline. + for (const comment of reviewComments) { + if ( + sameUser(comment.user?.login, login) || + isMaintainer(comment.author_association) + ) { + lastActivity = Math.max( + lastActivity, + Date.parse(comment.created_at) + ); + } + } + + // Match commits using the associated GitHub author account. + for (const commit of commits) { + const committedAt = Date.parse(commit.commit.author.date); + + if ( + sameUser(commit.author?.login, login) && + Number.isFinite(committedAt) + ) { + lastActivity = Math.max(lastActivity, committedAt); + } + } + } + + if (Date.now() - lastActivity < WARN_AFTER) continue; + + const marker = warningMarker(login); + const warning = events.find( + event => + event.event === "commented" && + event.body?.includes(marker) && + Date.parse(event.created_at) >= lastActivity ); + if (!warning) { + await comment( + issue.number, + `${marker}\n@${login}, there has been no qualifying activity for 5 days. ` + + `This issue will be unassigned in 3 days unless there is further activity.` + ); + } else if (Date.now() - Date.parse(warning.created_at) >= GRACE_PERIOD) { + await unassign(issue.number, login); + } + } catch (error) { + failures++; + console.error(`Failed to process #${issue.number} for ${login}`, error); } - - // The latest qualifying activity determines when the inactivity period starts. - const lastActivity = Math.max( - assignedAt, - ...comments - .filter( - c => - Date.parse(c.created_at) >= assignedAt && - same(c.user?.login, login) - ) - .map(c => Date.parse(c.created_at)), - ...prActivity - ); - - if (Date.now() - lastActivity < WARN_AFTER_DAYS * DAY) { - continue; - } - - const warning = comments.find( - c => - c.body.includes(warningMarker(login)) && - Date.parse(c.created_at) >= lastActivity - ); - - if (!warning) { - await comment( - issue.number, - `${warningMarker(login)}\n@${login}, there has been no qualifying activity ` + - `on this issue or its linked PR for ${WARN_AFTER_DAYS} days. It will be ` + - `unassigned in ${GRACE_DAYS} days unless there is further activity.` - ); - } else if ( - Date.now() - Date.parse(warning.created_at) >= GRACE_DAYS * DAY - ) { - await unassign( - issue.number, - login, - `there was no linked PR or activity for ${WARN_AFTER_DAYS + GRACE_DAYS} days` - ); - } - } catch (error) { - failures++; - console.error(`Failed on #${issue.number} (${login})`, error); } } } - if (failures > 0) { + + if (failures) { throw new Error(`Failed to process ${failures} assignment(s).`); } }; From a7d5949eca6da5d56bf9afe5101a685c97414eb0 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Tue, 3 Nov 2026 02:18:24 +0530 Subject: [PATCH 6/9] Simplify code and use github api --- .github/workflows/unassign_bot.js | 65 +++++++++++++++++++------------ 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index f6b486cff14e..e8d285e6fc3b 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -15,8 +15,6 @@ // specific language governing permissions and limitations // under the License. -const helpers = require("./dev_pr/helpers.js"); - const LABELS = ["good-first-issue", "good-second-issue"]; const PAUSE_LABELS = [ "awaiting review", @@ -42,7 +40,7 @@ module.exports = async ({ github, context }) => { const comment = (issue_number, body) => github.rest.issues.createComment({ owner, repo, issue_number, body }); - const unassign = async (issue_number, login) => { + const unassign = async (issue_number, login, message) => { console.log(`Unassigning ${login} from #${issue_number}`); await github.rest.issues.removeAssignees({ owner, @@ -50,14 +48,9 @@ module.exports = async ({ github, context }) => { issue_number, assignees: [login], }); - await comment( - issue_number, - `This issue was automatically unassigned from @${login} due to inactivity. ` + - `If you are still working on it, please comment here so a maintainer can reassign it.` - ); + await comment(issue_number, message); }; - const openPrs = await paginate(github.rest.pulls.list, { state: "open" }); const processed = new Set(); let failures = 0; @@ -76,28 +69,47 @@ module.exports = async ({ github, context }) => { for (const { login } of issue.assignees) { try { - const linkedPrs = openPrs.filter(pr => { - const ref = helpers.detectIssue(pr.title); - return ( - ref?.kind === "github" && - Number(ref.id) === issue.number && - sameUser(pr.user.login, login) - ); + // Timeline includes issue activity and the current details of linked PRs. + const events = await paginate(github.rest.issues.listEventsForTimeline, { + issue_number: issue.number, }); + const linkedPrs = events + .filter( + event => + event.event === "cross-referenced" && + event.source?.issue?.pull_request + ) + .map(event => event.source.issue) + .filter(pr => sameUser(pr.user?.login, login)); + + const openLinkedPrs = linkedPrs.filter(pr => pr.state === "open"); + // A linked PR awaiting review or merge pauses the inactivity timer. if ( - linkedPrs.some(pr => - pr.labels.some(label => PAUSE_LABELS.includes(label.name.toLowerCase())) + openLinkedPrs.some(pr => + pr.labels?.some(label => + PAUSE_LABELS.includes(label.name.toLowerCase()) + ) ) ) { continue; } - // Timeline events include assignment, comments, reviews and label changes. - const events = await paginate(github.rest.issues.listEventsForTimeline, { - issue_number: issue.number, - }); + // Unassign if a linked PR was closed without being merged. + const closedUnmergedPr = linkedPrs.find( + pr => pr.state === "closed" && !pr.pull_request.merged_at + ); + + if (closedUnmergedPr) { + await unassign( + issue.number, + login, + `This issue was automatically unassigned from @${login} because their linked pull request #${closedUnmergedPr.number} was closed without being merged.` + ); + continue; + } + const assignedAt = Math.max( 0, ...events @@ -118,7 +130,7 @@ module.exports = async ({ github, context }) => { } } - for (const pr of linkedPrs) { + for (const pr of openLinkedPrs) { const [prEvents, reviewComments, commits] = await Promise.all([ paginate(github.rest.issues.listEventsForTimeline, { issue_number: pr.number, @@ -191,7 +203,12 @@ module.exports = async ({ github, context }) => { `This issue will be unassigned in 3 days unless there is further activity.` ); } else if (Date.now() - Date.parse(warning.created_at) >= GRACE_PERIOD) { - await unassign(issue.number, login); + await unassign( + issue.number, + login, + `This issue was automatically unassigned from @${login} due to inactivity. ` + + `If you are still working on it, please comment here so a maintainer can reassign it.` + ); } } catch (error) { failures++; From 320ff4f154be72c24fe9f76a4aeccf7339155923 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Wed, 4 Nov 2026 21:53:04 +0530 Subject: [PATCH 7/9] Simplify Impl and Add Documentation --- .github/workflows/unassign_bot.js | 196 +++++++++++++++--------------- 1 file changed, 95 insertions(+), 101 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index e8d285e6fc3b..0dbfc028b2b2 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -26,22 +26,52 @@ const DAY = 24 * 60 * 60 * 1000; const WARN_AFTER = 5 * DAY; const GRACE_PERIOD = 3 * DAY; +/** + * Returns whether two GitHub usernames match, ignoring case. + * + * @param {String} a + * @param {String} b + */ const sameUser = (a, b) => Boolean(a && b && a.toLowerCase() === b.toLowerCase()); -const isMaintainer = association => - ["MEMBER", "OWNER", "COLLABORATOR"].includes(association); + +/** + * Returns a unique marker for the user's inactivity warning. + * + * @param {String} login + */ const warningMarker = login => ``; module.exports = async ({ github, context }) => { const { owner, repo } = context.repo; + + /** + * Retrieves all pages of a GitHub API response. + * + * @param {Function} method + * @param {Object} params + */ const paginate = (method, params) => github.paginate(method, { owner, repo, per_page: 100, ...params }); + + /** + * Posts a comment on the specified issue. + * + * @param {Number} issue_number + * @param {String} body + */ const comment = (issue_number, body) => github.rest.issues.createComment({ owner, repo, issue_number, body }); + /** + * Removes the user from an issue and explains why they were unassigned. + * + * @param {Number} issue_number + * @param {String} login + * @param {String} message + */ const unassign = async (issue_number, login, message) => { - console.log(`Unassigning ${login} from #${issue_number}`); await github.rest.issues.removeAssignees({ owner, repo, @@ -54,7 +84,7 @@ module.exports = async ({ github, context }) => { const processed = new Set(); let failures = 0; - // Process each issue only once, even when it has both newcomer labels. + // Process each newcomer label, avoiding duplicate issues. for (const label of LABELS) { const issues = await paginate(github.rest.issues.listForRepo, { state: "open", @@ -62,18 +92,45 @@ module.exports = async ({ github, context }) => { }); for (const issue of issues) { - if (issue.pull_request || !issue.assignees.length || processed.has(issue.number)) { + if ( + issue.pull_request || + !issue.assignees?.length || + processed.has(issue.number) + ) { continue; } processed.add(issue.number); + // Retrieve the timeline once and reuse it for every assignee. + let events; + try { + events = await paginate(github.rest.issues.listEventsForTimeline, { + issue_number: issue.number, + }); + } catch (error) { + failures++; + console.error(`Failed to retrieve timeline for #${issue.number}`, error); + continue; + } + for (const { login } of issue.assignees) { try { - // Timeline includes issue activity and the current details of linked PRs. - const events = await paginate(github.rest.issues.listEventsForTimeline, { - issue_number: issue.number, - }); + // Use the most recent assignment to reset the inactivity period. + const assignedAt = Math.max( + 0, + ...events + .filter( + event => + event.event === "assigned" && + sameUser(event.assignee?.login, login) + ) + .map(event => Date.parse(event.created_at)) + .filter(Number.isFinite) + ); + if (!assignedAt) continue; + + // Consider only open PRs authored by the assignee. const linkedPrs = events .filter( event => @@ -81,114 +138,48 @@ module.exports = async ({ github, context }) => { event.source?.issue?.pull_request ) .map(event => event.source.issue) - .filter(pr => sameUser(pr.user?.login, login)); - - const openLinkedPrs = linkedPrs.filter(pr => pr.state === "open"); + .filter( + pr => pr.state === "open" && sameUser(pr.user?.login, login) + ); - // A linked PR awaiting review or merge pauses the inactivity timer. + // Do not unassign users while their PRs are awaiting review or merge. if ( - openLinkedPrs.some(pr => - pr.labels?.some(label => - PAUSE_LABELS.includes(label.name.toLowerCase()) + linkedPrs.some(pr => + pr.labels?.some(({ name }) => + PAUSE_LABELS.includes(name?.toLowerCase()) ) ) ) { continue; } - // Unassign if a linked PR was closed without being merged. - const closedUnmergedPr = linkedPrs.find( - pr => pr.state === "closed" && !pr.pull_request.merged_at - ); - - if (closedUnmergedPr) { - await unassign( - issue.number, - login, - `This issue was automatically unassigned from @${login} because their linked pull request #${closedUnmergedPr.number} was closed without being merged.` - ); - continue; - } - - const assignedAt = Math.max( - 0, - ...events - .filter( - event => - event.event === "assigned" && - sameUser(event.assignee?.login, login) - ) - .map(event => Date.parse(event.created_at)) - ); - if (!assignedAt) continue; - - // Start at the assignment time so activity before assignment is ignored. + // Start with the assignment time and consider subsequent issue comments. let lastActivity = assignedAt; + for (const event of events) { - if (event.event === "commented" && sameUser(event.user?.login, login)) { - lastActivity = Math.max(lastActivity, Date.parse(event.created_at)); + if ( + event.event === "commented" && + sameUser(event.user?.login, login) + ) { + lastActivity = Math.max( + lastActivity, + Date.parse(event.created_at) + ); } } - for (const pr of openLinkedPrs) { - const [prEvents, reviewComments, commits] = await Promise.all([ - paginate(github.rest.issues.listEventsForTimeline, { - issue_number: pr.number, - }), - paginate(github.rest.pulls.listReviewComments, { - pull_number: pr.number, - }), - paginate(github.rest.pulls.listCommits, { - pull_number: pr.number, - }), - ]); - - for (const event of prEvents) { - const isCommentOrReview = - ["commented", "reviewed"].includes(event.event) && - (sameUser(event.user?.login, login) || - isMaintainer(event.author_association)); - - const isPauseLabelRemoved = - event.event === "unlabeled" && - PAUSE_LABELS.includes(event.label?.name?.toLowerCase()); - - if (isCommentOrReview || isPauseLabelRemoved) { - lastActivity = Math.max( - lastActivity, - Date.parse(event.submitted_at || event.created_at) - ); - } - } - - // Inline review comments are not included in the PR timeline. - for (const comment of reviewComments) { - if ( - sameUser(comment.user?.login, login) || - isMaintainer(comment.author_association) - ) { - lastActivity = Math.max( - lastActivity, - Date.parse(comment.created_at) - ); - } - } - - // Match commits using the associated GitHub author account. - for (const commit of commits) { - const committedAt = Date.parse(commit.commit.author.date); - - if ( - sameUser(commit.author?.login, login) && - Number.isFinite(committedAt) - ) { - lastActivity = Math.max(lastActivity, committedAt); - } + // Use the latest update time of linked PRs as an activity signal. + for (const pr of linkedPrs) { + const updatedAt = Date.parse(pr.updated_at); + if (Number.isFinite(updatedAt)) { + lastActivity = Math.max(lastActivity, updatedAt); } } - if (Date.now() - lastActivity < WARN_AFTER) continue; + const now = Date.now(); + if (now - lastActivity < WARN_AFTER) continue; + // Find an existing warning posted after the latest activity. const marker = warningMarker(login); const warning = events.find( event => @@ -196,13 +187,16 @@ module.exports = async ({ github, context }) => { event.body?.includes(marker) && Date.parse(event.created_at) >= lastActivity ); + if (!warning) { await comment( issue.number, `${marker}\n@${login}, there has been no qualifying activity for 5 days. ` + `This issue will be unassigned in 3 days unless there is further activity.` ); - } else if (Date.now() - Date.parse(warning.created_at) >= GRACE_PERIOD) { + } else if ( + now - Date.parse(warning.created_at) >= GRACE_PERIOD + ) { await unassign( issue.number, login, From 52503750e73ee9b3c9bd97ca9538bc18e3d6228f Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Wed, 4 Nov 2026 23:15:25 +0530 Subject: [PATCH 8/9] Remove Warning Message and make it 15-day inactivity period --- .github/workflows/unassign_bot.js | 44 ++++++------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index 0dbfc028b2b2..f4bd48439ca2 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -23,8 +23,7 @@ const PAUSE_LABELS = [ "awaiting merge", ]; const DAY = 24 * 60 * 60 * 1000; -const WARN_AFTER = 5 * DAY; -const GRACE_PERIOD = 3 * DAY; +const INACTIVITY_PERIOD = 15 * DAY; /** * Returns whether two GitHub usernames match, ignoring case. @@ -35,14 +34,6 @@ const GRACE_PERIOD = 3 * DAY; const sameUser = (a, b) => Boolean(a && b && a.toLowerCase() === b.toLowerCase()); -/** - * Returns a unique marker for the user's inactivity warning. - * - * @param {String} login - */ -const warningMarker = login => - ``; - module.exports = async ({ github, context }) => { const { owner, repo } = context.repo; @@ -176,34 +167,15 @@ module.exports = async ({ github, context }) => { } } - const now = Date.now(); - if (now - lastActivity < WARN_AFTER) continue; + if (Date.now() - lastActivity < INACTIVITY_PERIOD) continue; - // Find an existing warning posted after the latest activity. - const marker = warningMarker(login); - const warning = events.find( - event => - event.event === "commented" && - event.body?.includes(marker) && - Date.parse(event.created_at) >= lastActivity + await unassign( + issue.number, + login, + `@${login}, you have been automatically unassigned from this issue ` + + `after 8 days without activity. If you are still working on it, ` + + `please comment here so a maintainer can reassign it.` ); - - if (!warning) { - await comment( - issue.number, - `${marker}\n@${login}, there has been no qualifying activity for 5 days. ` + - `This issue will be unassigned in 3 days unless there is further activity.` - ); - } else if ( - now - Date.parse(warning.created_at) >= GRACE_PERIOD - ) { - await unassign( - issue.number, - login, - `This issue was automatically unassigned from @${login} due to inactivity. ` + - `If you are still working on it, please comment here so a maintainer can reassign it.` - ); - } } catch (error) { failures++; console.error(`Failed to process #${issue.number} for ${login}`, error); From 3a07ea5263627dbca94837e92a14da20ee5c3489 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Thu, 5 Nov 2026 18:52:03 +0530 Subject: [PATCH 9/9] Remove pause labels --- .github/workflows/unassign_bot.js | 75 +++++++++++++------------------ 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/.github/workflows/unassign_bot.js b/.github/workflows/unassign_bot.js index f4bd48439ca2..48671139e46f 100644 --- a/.github/workflows/unassign_bot.js +++ b/.github/workflows/unassign_bot.js @@ -16,12 +16,6 @@ // under the License. const LABELS = ["good-first-issue", "good-second-issue"]; -const PAUSE_LABELS = [ - "awaiting review", - "awaiting committer review", - "awaiting change review", - "awaiting merge", -]; const DAY = 24 * 60 * 60 * 1000; const INACTIVITY_PERIOD = 15 * DAY; @@ -92,7 +86,7 @@ module.exports = async ({ github, context }) => { } processed.add(issue.number); - // Retrieve the timeline once and reuse it for every assignee. + // Retrieve the timeline to determine assignments and linked PRs. let events; try { events = await paginate(github.rest.issues.listEventsForTimeline, { @@ -106,7 +100,7 @@ module.exports = async ({ github, context }) => { for (const { login } of issue.assignees) { try { - // Use the most recent assignment to reset the inactivity period. + // Use the most recent assignment to start the inactivity period. const assignedAt = Math.max( 0, ...events @@ -121,49 +115,44 @@ module.exports = async ({ github, context }) => { if (!assignedAt) continue; - // Consider only open PRs authored by the assignee. + // Find the assignee's linked PR. Each contributor is expected + // to have at most one linked PR for a particular issue. const linkedPrs = events .filter( event => event.event === "cross-referenced" && - event.source?.issue?.pull_request + event.source?.issue?.pull_request && + sameUser(event.source.issue.user?.login, login) ) - .map(event => event.source.issue) - .filter( - pr => pr.state === "open" && sameUser(pr.user?.login, login) - ); - - // Do not unassign users while their PRs are awaiting review or merge. - if ( - linkedPrs.some(pr => - pr.labels?.some(({ name }) => - PAUSE_LABELS.includes(name?.toLowerCase()) - ) - ) - ) { - continue; - } + .map(event => event.source.issue); - // Start with the assignment time and consider subsequent issue comments. let lastActivity = assignedAt; - for (const event of events) { + // Check the most recently updated open PR authored by the assignee. + const linkedPr = linkedPrs + .filter(pr => pr.state === "open") + .sort( + (a, b) => + Date.parse(b.updated_at) - Date.parse(a.updated_at) + )[0]; + + if (linkedPr) { + // Fetch the latest PR state and activity timestamp. + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: linkedPr.number, + }); + + // Only an open PR authored by the assignee counts as activity. if ( - event.event === "commented" && - sameUser(event.user?.login, login) + pr.state === "open" && + sameUser(pr.user?.login, login) ) { - lastActivity = Math.max( - lastActivity, - Date.parse(event.created_at) - ); - } - } - - // Use the latest update time of linked PRs as an activity signal. - for (const pr of linkedPrs) { - const updatedAt = Date.parse(pr.updated_at); - if (Number.isFinite(updatedAt)) { - lastActivity = Math.max(lastActivity, updatedAt); + const updatedAt = Date.parse(pr.updated_at); + if (Number.isFinite(updatedAt)) { + lastActivity = Math.max(lastActivity, updatedAt); + } } } @@ -173,8 +162,8 @@ module.exports = async ({ github, context }) => { issue.number, login, `@${login}, you have been automatically unassigned from this issue ` + - `after 8 days without activity. If you are still working on it, ` + - `please comment here so a maintainer can reassign it.` + `after 15 days without activity on your linked PR. If you are ` + + `still working on it, please comment here so a maintainer can reassign it.` ); } catch (error) { failures++;