From c03a136539702f7b4006163a03b68444fbc93102 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:40:27 -0700 Subject: [PATCH 1/8] Align issue and PR auto-close automation across Azure Monitor repositories - needs-author-feedback.yml / needs-author-feedback-followup.yml: notify the author when the label is added, remind at 7 days, close at 14 days, and drop the label when the author replies - stale.yml: 90 days stale + 14 days close for general inactivity (oldest first, throttled); PRs inactive 30 days marked abandoned; wontfix/abandoned closed after 14+14 days; jobs run sequentially Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 177 ++++++++++++++++++ .github/workflows/needs-author-feedback.yml | 81 ++++++++ .github/workflows/stale-assigned.yml | 30 --- .github/workflows/stale.yml | 98 ++++++---- 4 files changed, 315 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/needs-author-feedback-followup.yml create mode 100644 .github/workflows/needs-author-feedback.yml delete mode 100644 .github/workflows/stale-assigned.yml diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml new file mode 100644 index 000000000..8144753f7 --- /dev/null +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -0,0 +1,177 @@ +name: needs-author-feedback followup + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + issues: write + +jobs: + follow-up: + runs-on: ubuntu-latest + steps: + - name: Remind or close issues waiting for author feedback + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const now = new Date(); + + function daysBetween(earlier, later) { + return Math.floor((later - earlier) / (1000 * 60 * 60 * 24)); + } + + function getLatestAppliedMarker(comments) { + const matching = comments.filter(comment => + comment.user?.type === "Bot" && + comment.body?.includes("") + ); + + if (matching.length === 0) { + return null; + } + + matching.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + return matching[0]; + } + + function hasReminderSince(comments, appliedAt) { + return comments.some(comment => + comment.user?.type === "Bot" && + comment.body?.includes("") && + new Date(comment.created_at) > appliedAt + ); + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + labels: "needs-author-feedback", + per_page: 100 + }); + + for (const issue of issues) { + if (issue.pull_request) continue; + + const issue_number = issue.number; + const author = issue.user.login; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100 + }); + + const latestAppliedComment = getLatestAppliedMarker(comments); + if (!latestAppliedComment) { + // Backfill an applied marker for issues labeled before this + // workflow existed (or where the marker comment was deleted). + // Without this, such issues would stay labeled forever because + // the reminder/close logic below keys off the marker timestamp. + // The backfilled comment uses today's date as the start of the + // 14-day clock and intentionally does not @-mention the author. + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: [ + `Backfilling \`needs-author-feedback\` follow-up tracker on ${now.toISOString().slice(0, 10)}.`, + "The reminder/auto-close clock starts from this comment.", + "", + "" + ].join("\n") + }); + continue; + } + + const appliedAt = new Date(latestAppliedComment.created_at); + + const authorReplied = comments.some(comment => + comment.user?.login === author && + new Date(comment.created_at) > appliedAt + ); + + if (authorReplied) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: "Thanks for the response. Removing `needs-author-feedback`." + }); + + continue; + } + + const ageInDays = daysBetween(appliedAt, now); + + if (ageInDays >= 14) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: [ + `@${author} we have not received the requested information within 14 days, so we are closing this issue for now.`, + "", + "If you still need help, please comment with the requested details and we can reopen it.", + "", + "" + ].join("\n") + }); + + // Remove the label before closing so that if a maintainer later + // reopens the issue, this workflow does not immediately re-close + // it based on the stale `applied` marker. When the label is + // re-applied to gather more info, a fresh `applied` marker + // restarts the 14-day clock. + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed" + }); + + continue; + } + + if (ageInDays >= 7 && !hasReminderSince(comments, appliedAt)) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: [ + `@${author} friendly reminder: we are still waiting for your feedback.`, + "", + "If we do not hear back within 7 more days, this issue may be closed automatically.", + "", + "" + ].join("\n") + }); + } + } diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml new file mode 100644 index 000000000..b714d3845 --- /dev/null +++ b/.github/workflows/needs-author-feedback.yml @@ -0,0 +1,81 @@ +name: needs-author-feedback + +on: + issues: + types: [labeled] + issue_comment: + types: [created] + +permissions: + issues: write + +jobs: + notify-on-label: + if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'needs-author-feedback' + runs-on: ubuntu-latest + steps: + - name: Notify issue author + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const issue = context.payload.issue; + if (issue.pull_request) return; + + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = issue.number; + const author = issue.user.login; + const today = new Date().toISOString().slice(0, 10); + + const body = [ + `@${author} we need more information before we can continue working on this issue.`, + "", + "Please reply within 14 days. If we do not hear back, this issue may be closed automatically.", + "", + "", + `Marked as needs-author-feedback on ${today}.` + ].join("\n"); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body + }); + + remove-label-on-author-reply: + if: github.event_name == 'issue_comment' && github.event.action == 'created' + runs-on: ubuntu-latest + steps: + - name: Remove label when issue author replies + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const issue = context.payload.issue; + const comment = context.payload.comment; + + if (issue.pull_request) return; + if (!issue.labels.some(label => label.name === "needs-author-feedback")) return; + if (comment.user.login !== issue.user.login) return; + + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = issue.number; + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: "Thanks for the response. Removing `needs-author-feedback`." + }); diff --git a/.github/workflows/stale-assigned.yml b/.github/workflows/stale-assigned.yml deleted file mode 100644 index ea6e5552c..000000000 --- a/.github/workflows/stale-assigned.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: 'Stale issue handler' -on: - workflow_dispatch: - schedule: - - cron: '0 7 * * *' - -permissions: - actions: write - issues: write - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - id: stale - with: - stale-issue-message: 'This Issue will be closed in 30 days. Please remove the "Stale" label or comment to avoid closure with no action.' - stale-pr-message: 'This PR will be closed in 30 days. Please remove the "Stale" label or comment to avoid closure with no action.' - operations-per-run: 200 - days-before-stale: 300 - days-before-close: 30 - stale-issue-label: 'stale' - stale-pr-label: 'stale' - close-issue-label: 'closed' - close-pr-label: 'closed' - exempt-issue-labels: 'bug,enhancement,documentation,waiting,keep' - exempt-pr-labels: 'waiting,keep' - include-only-assigned: true \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 889265bb2..92efa8ab0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,70 +1,86 @@ -name: 'Stale issue handler' +# Shared stale/auto-close policy for Azure Monitor repositories. +# Keep this file identical across repos. Author follow-up is handled separately by +# needs-author-feedback.yml / needs-author-feedback-followup.yml. +name: "Stale issue handler" on: workflow_dispatch: schedule: - - cron: '0 7 * * *' + - cron: "0 7 * * *" permissions: actions: write issues: write pull-requests: write +concurrency: + group: stale-handler + cancel-in-progress: false + jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - id: stale - with: - stale-issue-message: 'This Issue will be closed in 30 days. Please remove the "Stale" label or comment to avoid closure with no action.' - stale-pr-message: 'This PR will be closed in 30 days. Please remove the "Stale" label or comment to avoid closure with no action.' - operations-per-run: 200 - days-before-stale: 240 - days-before-close: 30 - stale-issue-label: 'stale' - stale-pr-label: 'stale' - close-issue-label: 'closed' - close-pr-label: 'closed' - exempt-issue-labels: 'bug,enhancement,documentation,waiting,keep' - exempt-pr-labels: 'waiting,keep' - exempt-all-milestones: true - exempt-all-assignees: true - + # 1. PRs with no activity for 30 days are flagged as abandoned (closed later by stale-wontfix). mark-abandoned: runs-on: ubuntu-latest steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - id: mark-abandoned with: - days-before-stale: 30 - days-before-close: -1 days-before-issue-stale: -1 days-before-issue-close: -1 - stale-pr-message: 'This PR has been inactive for 30 days and has been marked as abandoned. You can remove this label by commenting or pushing new changes. If it remains inactive with the abandoned label, it will eventually also be marked as stale and closed.' - stale-pr-label: 'abandoned' - exempt-pr-labels: 'keep,wontfix,stale' - operations-per-run: 100 - remove-stale-when-updated: true + days-before-pr-stale: 30 + days-before-pr-close: -1 + stale-pr-message: "This PR has been inactive for 30 days and has been marked as abandoned. Comment or push changes to remove the label; otherwise it will be marked stale and closed." + stale-pr-label: "abandoned" + exempt-pr-labels: "keep,wontfix,stale,needs-author-feedback" + exempt-draft-pr: false exempt-all-milestones: false exempt-all-assignees: false + operations-per-run: 100 + # 2. wontfix / abandoned items: stale after 14 days, closed 14 days later. stale-wontfix: + needs: mark-abandoned + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - id: stale-wontfix with: - stale-issue-message: 'This Issue will be closed in 14 days. Please remove the "Stale" label or comment to avoid closure with no action.' - stale-pr-message: 'This PR will be closed in 14 days. Please remove the "Stale" label or comment to avoid closure with no action.' - operations-per-run: 100 + any-of-labels: "wontfix,abandoned" days-before-stale: 14 days-before-close: 14 - stale-issue-label: 'stale' - stale-pr-label: 'stale' - close-issue-label: 'closed' - close-pr-label: 'closed' - any-of-labels: 'wontfix,abandoned' - exempt-issue-labels: 'keep' - exempt-pr-labels: 'keep' + stale-issue-message: 'This issue will be closed in 14 days. Comment or add the "keep" label to prevent closure.' + stale-pr-message: 'This PR will be closed in 14 days. Comment, push changes, or add the "keep" label to prevent closure.' + stale-issue-label: "stale" + stale-pr-label: "stale" + close-issue-label: "closed" + close-pr-label: "closed" + close-issue-reason: "not_planned" + exempt-issue-labels: "keep" + exempt-pr-labels: "keep" exempt-all-milestones: false exempt-all-assignees: false + operations-per-run: 100 + + # 3. General inactivity: anything quiet for 90 days goes stale and closes 14 days later. + # Throttled and oldest-first so long-standing backlogs are worked down gradually. + # Bugs, items waiting on an external party, "keep", and milestoned items are exempt. + stale: + needs: stale-wontfix + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + days-before-stale: 90 + days-before-close: 14 + stale-issue-message: 'This issue has had no activity for 90 days and will be closed in 14 days. Comment or add the "keep" label to prevent closure.' + stale-pr-message: 'This PR has had no activity for 90 days and will be closed in 14 days. Comment, push changes, or add the "keep" label to prevent closure.' + stale-issue-label: "stale" + stale-pr-label: "stale" + close-issue-label: "closed" + close-pr-label: "closed" + close-issue-reason: "not_planned" + exempt-issue-labels: "bug,keep,needs-author-feedback,needs-team-attention,waiting-external,waiting,waiting for backend fix,waiting - CDN deployment,fixed - waiting release" + exempt-pr-labels: "keep,needs-author-feedback,waiting-external,waiting" + exempt-all-milestones: true + exempt-all-assignees: false + ascending: true + operations-per-run: 15 From e770f44ed6f288c460afa51a39b007087e2793d4 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:46:35 -0700 Subject: [PATCH 2/8] Address review feedback on issue/PR auto-close automation - stale.yml: use a single actions/stale invocation. The action persists progress under one fixed cache key, so multiple throttled jobs overwrote each other's checkpoints. Issues: 90 days stale + 14 close. PRs: 30 days stale + 14 close. This replaces the separate abandoned/wontfix jobs, applies one consistent PR exemption list, and lets PRs labeled needs-author-feedback close normally. - needs-author-feedback-followup.yml: skip issues labeled keep, and remove the label before closing so an author reply racing the run (label already gone) leaves the issue open. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 38 +++++----- .github/workflows/stale.yml | 72 +++++-------------- 2 files changed, 41 insertions(+), 69 deletions(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index 8144753f7..71d70e406 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -56,6 +56,8 @@ jobs: for (const issue of issues) { if (issue.pull_request) continue; + // `keep` opts an issue out of all automatic closure. + if (issue.labels.some(label => (typeof label === "string" ? label : label.name) === "keep")) continue; const issue_number = issue.number; const author = issue.user.login; @@ -121,24 +123,13 @@ jobs: const ageInDays = daysBetween(appliedAt, now); if (ageInDays >= 14) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: [ - `@${author} we have not received the requested information within 14 days, so we are closing this issue for now.`, - "", - "If you still need help, please comment with the requested details and we can reopen it.", - "", - "" - ].join("\n") - }); - // Remove the label before closing so that if a maintainer later // reopens the issue, this workflow does not immediately re-close // it based on the stale `applied` marker. When the label is // re-applied to gather more info, a fresh `applied` marker - // restarts the 14-day clock. + // restarts the 14-day clock. A 404 means the label was removed + // after this run started (e.g. the author just replied), so the + // issue is left open. try { await github.rest.issues.removeLabel({ owner, @@ -147,14 +138,29 @@ jobs: name: "needs-author-feedback" }); } catch (error) { - if (error.status !== 404) throw error; + if (error.status === 404) continue; + throw error; } + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: [ + `@${author} we have not received the requested information within 14 days, so we are closing this issue for now.`, + "", + "If you still need help, please comment with the requested details and we can reopen it.", + "", + "" + ].join("\n") + }); + await github.rest.issues.update({ owner, repo, issue_number, - state: "closed" + state: "closed", + state_reason: "not_planned" }); continue; diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 92efa8ab0..3422476a4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,6 +1,9 @@ # Shared stale/auto-close policy for Azure Monitor repositories. -# Keep this file identical across repos. Author follow-up is handled separately by +# Keep this file identical across repos. Author follow-up for issues is handled separately by # needs-author-feedback.yml / needs-author-feedback-followup.yml. +# +# A single actions/stale invocation is used on purpose: the action persists its progress under one +# fixed cache key, so multiple invocations in the same repo would overwrite each other's progress. name: "Stale issue handler" on: workflow_dispatch: @@ -17,70 +20,33 @@ concurrency: cancel-in-progress: false jobs: - # 1. PRs with no activity for 30 days are flagged as abandoned (closed later by stale-wontfix). - mark-abandoned: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - with: - days-before-issue-stale: -1 - days-before-issue-close: -1 - days-before-pr-stale: 30 - days-before-pr-close: -1 - stale-pr-message: "This PR has been inactive for 30 days and has been marked as abandoned. Comment or push changes to remove the label; otherwise it will be marked stale and closed." - stale-pr-label: "abandoned" - exempt-pr-labels: "keep,wontfix,stale,needs-author-feedback" - exempt-draft-pr: false - exempt-all-milestones: false - exempt-all-assignees: false - operations-per-run: 100 - - # 2. wontfix / abandoned items: stale after 14 days, closed 14 days later. - stale-wontfix: - needs: mark-abandoned - if: ${{ !cancelled() }} - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - with: - any-of-labels: "wontfix,abandoned" - days-before-stale: 14 - days-before-close: 14 - stale-issue-message: 'This issue will be closed in 14 days. Comment or add the "keep" label to prevent closure.' - stale-pr-message: 'This PR will be closed in 14 days. Comment, push changes, or add the "keep" label to prevent closure.' - stale-issue-label: "stale" - stale-pr-label: "stale" - close-issue-label: "closed" - close-pr-label: "closed" - close-issue-reason: "not_planned" - exempt-issue-labels: "keep" - exempt-pr-labels: "keep" - exempt-all-milestones: false - exempt-all-assignees: false - operations-per-run: 100 - - # 3. General inactivity: anything quiet for 90 days goes stale and closes 14 days later. - # Throttled and oldest-first so long-standing backlogs are worked down gradually. - # Bugs, items waiting on an external party, "keep", and milestoned items are exempt. + # Issues: stale after 90 inactive days, closed 14 days later. + # PRs: stale after 30 inactive days, closed 14 days later. + # Oldest first and throttled so existing backlogs are worked down gradually; progress carries over between runs. stale: - needs: stale-wontfix - if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: - days-before-stale: 90 - days-before-close: 14 + days-before-issue-stale: 90 + days-before-issue-close: 14 + days-before-pr-stale: 30 + days-before-pr-close: 14 stale-issue-message: 'This issue has had no activity for 90 days and will be closed in 14 days. Comment or add the "keep" label to prevent closure.' - stale-pr-message: 'This PR has had no activity for 90 days and will be closed in 14 days. Comment, push changes, or add the "keep" label to prevent closure.' + stale-pr-message: 'This PR has had no activity for 30 days and will be closed in 14 days. Comment, push changes, or add the "keep" label to prevent closure.' + close-issue-message: "Closing due to inactivity. Please comment if this is still relevant and we can reopen it." + close-pr-message: "Closing due to inactivity. Feel free to reopen when you are ready to continue." stale-issue-label: "stale" stale-pr-label: "stale" close-issue-label: "closed" close-pr-label: "closed" close-issue-reason: "not_planned" + # needs-author-feedback issues are handled by needs-author-feedback-followup.yml (issues only), + # so PRs with that label still go through the PR inactivity policy here. exempt-issue-labels: "bug,keep,needs-author-feedback,needs-team-attention,waiting-external,waiting,waiting for backend fix,waiting - CDN deployment,fixed - waiting release" - exempt-pr-labels: "keep,needs-author-feedback,waiting-external,waiting" + exempt-pr-labels: "keep,needs-team-attention,waiting-external,waiting" exempt-all-milestones: true exempt-all-assignees: false + exempt-draft-pr: false ascending: true - operations-per-run: 15 + operations-per-run: 25 From ba778b5d2665ea40d7189b5c8b15fb6d26507531 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:58:05 -0700 Subject: [PATCH 3/8] Address second round of review feedback on auto-close automation - Re-check issue state, labels and author replies right before auto-closing - Remove needs-author-feedback only after a successful close so failures retry - Ignore applied markers from an earlier feedback round - Reopen auto-closed issues when the author replies - Exempt waiting labels for PRs too Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 84 ++++++++++++++----- .github/workflows/needs-author-feedback.yml | 54 +++++++++++- .github/workflows/stale.yml | 7 +- 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index 71d70e406..f52ac1b4b 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -38,6 +38,33 @@ jobs: return matching[0]; } + function hasLabel(item, name) { + return (item.labels || []).some(label => (typeof label === "string" ? label : label.name) === name); + } + + // Time the needs-author-feedback label was most recently applied, so a + // marker from an earlier feedback round is never reused for a new one. + async function getLatestLabeledAt(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100 + }); + + let latest = null; + for (const event of events) { + if (event.event === "labeled" && event.label?.name === "needs-author-feedback") { + const at = new Date(event.created_at); + if (!latest || at > latest) { + latest = at; + } + } + } + + return latest; + } + function hasReminderSince(comments, appliedAt) { return comments.some(comment => comment.user?.type === "Bot" && @@ -57,7 +84,7 @@ jobs: for (const issue of issues) { if (issue.pull_request) continue; // `keep` opts an issue out of all automatic closure. - if (issue.labels.some(label => (typeof label === "string" ? label : label.name) === "keep")) continue; + if (hasLabel(issue, "keep")) continue; const issue_number = issue.number; const author = issue.user.login; @@ -70,9 +97,13 @@ jobs: }); const latestAppliedComment = getLatestAppliedMarker(comments); - if (!latestAppliedComment) { + const labeledAt = await getLatestLabeledAt(issue_number); + const markerIsCurrent = latestAppliedComment && + (!labeledAt || new Date(latestAppliedComment.created_at) >= labeledAt); + if (!markerIsCurrent) { // Backfill an applied marker for issues labeled before this - // workflow existed (or where the marker comment was deleted). + // workflow existed, or where the marker for the current label + // round is missing (notification failed or was deleted). // Without this, such issues would stay labeled forever because // the reminder/close logic below keys off the marker timestamp. // The backfilled comment uses today's date as the start of the @@ -123,24 +154,20 @@ jobs: const ageInDays = daysBetween(appliedAt, now); if (ageInDays >= 14) { - // Remove the label before closing so that if a maintainer later - // reopens the issue, this workflow does not immediately re-close - // it based on the stale `applied` marker. When the label is - // re-applied to gather more info, a fresh `applied` marker - // restarts the 14-day clock. A 404 means the label was removed - // after this run started (e.g. the author just replied), so the - // issue is left open. - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number, - name: "needs-author-feedback" - }); - } catch (error) { - if (error.status === 404) continue; - throw error; - } + // Re-check the current state right before closing: the snapshot + // above may be stale if the author replied or a maintainer added + // `keep` / removed the label since this run started. + const { data: current } = await github.rest.issues.get({ owner, repo, issue_number }); + if (current.state !== "open" || !hasLabel(current, "needs-author-feedback") || hasLabel(current, "keep")) continue; + + const recentComments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + since: appliedAt.toISOString(), + per_page: 100 + }); + if (recentComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > appliedAt)) continue; await github.rest.issues.createComment({ owner, @@ -163,6 +190,21 @@ jobs: state_reason: "not_planned" }); + // Remove the label only after a successful close so a failed close + // is retried on the next run, and a later manual reopen does not + // immediately re-close the issue from the old `applied` marker. + // A fresh label application restarts the 14-day clock. + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } + continue; } diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml index b714d3845..5f90f9084 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -55,13 +55,65 @@ jobs: const comment = context.payload.comment; if (issue.pull_request) return; - if (!issue.labels.some(label => label.name === "needs-author-feedback")) return; if (comment.user.login !== issue.user.login) return; const owner = context.repo.owner; const repo = context.repo.repo; const issue_number = issue.number; + if (issue.state === "closed") { + // Reopen an issue that the follow-up workflow auto-closed for missing + // feedback when the author replies afterwards (including a reply that + // raced the close). + if (issue.state_reason !== "not_planned") return; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100 + }); + const closedMarkers = comments.filter(c => + c.user?.type === "Bot" && + c.body?.includes("") + ); + if (closedMarkers.length === 0) return; + + const closedMarker = closedMarkers[closedMarkers.length - 1]; + const markerAt = new Date(closedMarker.created_at); + // Only when the current closure is the automatic one (not a later manual close). + if (Math.abs(new Date(issue.closed_at) - markerAt) > 10 * 60 * 1000) return; + if (new Date(comment.created_at) <= markerAt) return; + + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "open" + }); + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: "Thanks for the response. Reopening this issue." + }); + return; + } + + if (!issue.labels.some(label => label.name === "needs-author-feedback")) return; + try { await github.rest.issues.removeLabel({ owner, diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3422476a4..16758d363 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -41,10 +41,11 @@ jobs: close-issue-label: "closed" close-pr-label: "closed" close-issue-reason: "not_planned" - # needs-author-feedback issues are handled by needs-author-feedback-followup.yml (issues only), - # so PRs with that label still go through the PR inactivity policy here. + # PRs share the issue exemptions except `bug` (a bug label on a PR does not mean the PR should stay open) + # and `needs-author-feedback` (the feedback workflows handle issues only, so feedback-labeled PRs + # still need this inactivity policy to close). exempt-issue-labels: "bug,keep,needs-author-feedback,needs-team-attention,waiting-external,waiting,waiting for backend fix,waiting - CDN deployment,fixed - waiting release" - exempt-pr-labels: "keep,needs-team-attention,waiting-external,waiting" + exempt-pr-labels: "keep,needs-team-attention,waiting-external,waiting,waiting for backend fix,waiting - CDN deployment,fixed - waiting release" exempt-all-milestones: true exempt-all-assignees: false exempt-draft-pr: false From f129e59c52ace6a51e0a656731332f4596654eb3 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:06:56 -0700 Subject: [PATCH 4/8] Harden needs-author-feedback against label-round and close races - Skip auto-close if the label was reapplied during the run - Reopen if the author replied while the issue was being closed - Reply handler uses current issue state, ignores replies older than the latest label, and only reopens bot closures from the current round Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 28 +++++++++++ .github/workflows/needs-author-feedback.yml | 46 +++++++++++++------ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index f52ac1b4b..e19c7c78c 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -160,6 +160,10 @@ jobs: const { data: current } = await github.rest.issues.get({ owner, repo, issue_number }); if (current.state !== "open" || !hasLabel(current, "needs-author-feedback") || hasLabel(current, "keep")) continue; + // Skip if the label was removed and reapplied (a new feedback round) since it was inspected. + const currentLabeledAt = await getLatestLabeledAt(issue_number); + if (String(currentLabeledAt) !== String(labeledAt)) continue; + const recentComments = await github.paginate(github.rest.issues.listComments, { owner, repo, @@ -205,6 +209,30 @@ jobs: if (error.status !== 404) throw error; } + // An author reply that landed between the final check and the close + // may have been handled while the issue was still open; reopen it. + const lateComments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + since: appliedAt.toISOString(), + per_page: 100 + }); + if (lateComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > appliedAt)) { + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "open" + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: "Thanks for the response. Reopening this issue." + }); + } + continue; } diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml index 5f90f9084..92d73898d 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -61,29 +61,45 @@ jobs: const repo = context.repo.repo; const issue_number = issue.number; - if (issue.state === "closed") { - // Reopen an issue that the follow-up workflow auto-closed for missing - // feedback when the author replies afterwards (including a reply that - // raced the close). - if (issue.state_reason !== "not_planned") return; + // Use the current state rather than the event snapshot: the job may run + // after the follow-up workflow closed the issue or the label was reapplied. + const { data: current } = await github.rest.issues.get({ owner, repo, issue_number }); + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100 + }); + + const latest = (type, match) => + events.filter(e => e.event === type && match(e)).sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; + const labeled = latest("labeled", e => e.label?.name === "needs-author-feedback"); + if (!labeled) return; + + const labeledAt = new Date(labeled.created_at); + // The reply answers an earlier feedback round; leave the newer request alone. + if (new Date(comment.created_at) < labeledAt) return; + + if (current.state === "closed") { + // Reopen only when the current closure is the automatic feedback closure + // for this round, not a manual decision by a maintainer. + const closed = latest("closed", () => true); + if (!closed || closed.actor?.type !== "Bot" || closed.state_reason !== "not_planned") return; + if (new Date(closed.created_at) < labeledAt) return; const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, + since: labeledAt.toISOString(), per_page: 100 }); - const closedMarkers = comments.filter(c => + const autoClosed = comments.some(c => c.user?.type === "Bot" && - c.body?.includes("") + c.body?.includes("") && + new Date(c.created_at) >= labeledAt ); - if (closedMarkers.length === 0) return; - - const closedMarker = closedMarkers[closedMarkers.length - 1]; - const markerAt = new Date(closedMarker.created_at); - // Only when the current closure is the automatic one (not a later manual close). - if (Math.abs(new Date(issue.closed_at) - markerAt) > 10 * 60 * 1000) return; - if (new Date(comment.created_at) <= markerAt) return; + if (!autoClosed) return; await github.rest.issues.update({ owner, @@ -112,7 +128,7 @@ jobs: return; } - if (!issue.labels.some(label => label.name === "needs-author-feedback")) return; + if (!current.labels.some(label => label.name === "needs-author-feedback")) return; try { await github.rest.issues.removeLabel({ From 1eed3d1df55bef5b8ce51f42384f68de6eddbbc5 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:16:50 -0700 Subject: [PATCH 5/8] Fix reopen reason check and count replies from the label round - Read state_reason from the fetched issue (closed events do not expose it) - Count author replies from the latest label application so answers before a backfilled marker are honored Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/needs-author-feedback-followup.yml | 13 ++++++++----- .github/workflows/needs-author-feedback.yml | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index e19c7c78c..ee5cc9ba1 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -123,10 +123,13 @@ jobs: } const appliedAt = new Date(latestAppliedComment.created_at); + // Replies count from the start of the label round, so an answer posted before a + // backfilled marker is not ignored. The marker still drives the 7/14-day clock. + const repliesSince = labeledAt && labeledAt < appliedAt ? labeledAt : appliedAt; const authorReplied = comments.some(comment => comment.user?.login === author && - new Date(comment.created_at) > appliedAt + new Date(comment.created_at) > repliesSince ); if (authorReplied) { @@ -168,10 +171,10 @@ jobs: owner, repo, issue_number, - since: appliedAt.toISOString(), + since: repliesSince.toISOString(), per_page: 100 }); - if (recentComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > appliedAt)) continue; + if (recentComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince)) continue; await github.rest.issues.createComment({ owner, @@ -215,10 +218,10 @@ jobs: owner, repo, issue_number, - since: appliedAt.toISOString(), + since: repliesSince.toISOString(), per_page: 100 }); - if (lateComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > appliedAt)) { + if (lateComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince)) { await github.rest.issues.update({ owner, repo, diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml index 92d73898d..2868649c9 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -84,7 +84,7 @@ jobs: // Reopen only when the current closure is the automatic feedback closure // for this round, not a manual decision by a maintainer. const closed = latest("closed", () => true); - if (!closed || closed.actor?.type !== "Bot" || closed.state_reason !== "not_planned") return; + if (!closed || closed.actor?.type !== "Bot" || current.state_reason !== "not_planned") return; if (new Date(closed.created_at) < labeledAt) return; const comments = await github.paginate(github.rest.issues.listComments, { From 5ab80270f70db37d4b3a566d6f35d418d004a3bf Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:31:28 -0700 Subject: [PATCH 6/8] Skip bot-authored issues and scope reopen to the current close cycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/needs-author-feedback-followup.yml | 2 ++ .github/workflows/needs-author-feedback.yml | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index ee5cc9ba1..caf34c0ce 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -85,6 +85,8 @@ jobs: if (issue.pull_request) continue; // `keep` opts an issue out of all automatic closure. if (hasLabel(issue, "keep")) continue; + // Bot-authored issues cannot answer, and the bot's own comments would look like replies. + if (issue.user?.type === "Bot") continue; const issue_number = issue.number; const author = issue.user.login; diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml index 2868649c9..b539c3c21 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -55,6 +55,7 @@ jobs: const comment = context.payload.comment; if (issue.pull_request) return; + if (issue.user?.type === "Bot") return; if (comment.user.login !== issue.user.login) return; const owner = context.repo.owner; @@ -94,10 +95,15 @@ jobs: since: labeledAt.toISOString(), per_page: 100 }); + // The feedback-close marker must belong to the current open/close cycle, + // so a later stale.yml closure after a manual reopen is not reversed. + const reopened = latest("reopened", () => true); + const cycleStart = reopened && new Date(reopened.created_at) > labeledAt ? new Date(reopened.created_at) : labeledAt; const autoClosed = comments.some(c => c.user?.type === "Bot" && c.body?.includes("") && - new Date(c.created_at) >= labeledAt + new Date(c.created_at) >= cycleStart && + new Date(c.created_at) <= new Date(closed.created_at) ); if (!autoClosed) return; From dc64a7cc08e219b49fbdab2d3b21afccf95fe773 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:40:00 -0700 Subject: [PATCH 7/8] Only reopen when the feedback-close marker belongs to the actual close Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/needs-author-feedback.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/needs-author-feedback.yml b/.github/workflows/needs-author-feedback.yml index b539c3c21..187086bd4 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -103,7 +103,10 @@ jobs: c.user?.type === "Bot" && c.body?.includes("") && new Date(c.created_at) >= cycleStart && - new Date(c.created_at) <= new Date(closed.created_at) + new Date(c.created_at) <= new Date(closed.created_at) && + // The follow-up closes right after posting the marker; a marker from a failed + // attempt must not vouch for an unrelated later closure. + new Date(closed.created_at) - new Date(c.created_at) <= 5 * 60 * 1000 ); if (!autoClosed) return; From 516c9a6632e97f4b9be0167f15aa4ceccfb1e18b Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:47:49 -0700 Subject: [PATCH 8/8] Guard late-reply reopen and clean up leftover feedback label Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 85 ++++++++++++------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index caf34c0ce..0ca0b2f2f 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -42,27 +42,39 @@ jobs: return (item.labels || []).some(label => (typeof label === "string" ? label : label.name) === name); } - // Time the needs-author-feedback label was most recently applied, so a - // marker from an earlier feedback round is never reused for a new one. - async function getLatestLabeledAt(issue_number) { - const events = await github.paginate(github.rest.issues.listEvents, { + async function getEvents(issue_number) { + return github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number, per_page: 100 }); + } - let latest = null; - for (const event of events) { - if (event.event === "labeled" && event.label?.name === "needs-author-feedback") { - const at = new Date(event.created_at); - if (!latest || at > latest) { - latest = at; - } - } - } + function latestEvent(events, type, match = () => true) { + return events + .filter(event => event.event === type && match(event)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; + } - return latest; + // Time the needs-author-feedback label was most recently applied, so a + // marker from an earlier feedback round is never reused for a new one. + function getLatestLabeledAt(events) { + const labeled = latestEvent(events, "labeled", event => event.label?.name === "needs-author-feedback"); + return labeled ? new Date(labeled.created_at) : null; + } + + async function removeFeedbackLabel(issue_number) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: "needs-author-feedback" + }); + } catch (error) { + if (error.status !== 404) throw error; + } } function hasReminderSince(comments, appliedAt) { @@ -99,7 +111,8 @@ jobs: }); const latestAppliedComment = getLatestAppliedMarker(comments); - const labeledAt = await getLatestLabeledAt(issue_number); + const events = await getEvents(issue_number); + const labeledAt = getLatestLabeledAt(events); const markerIsCurrent = latestAppliedComment && (!labeledAt || new Date(latestAppliedComment.created_at) >= labeledAt); if (!markerIsCurrent) { @@ -129,6 +142,14 @@ jobs: // backfilled marker is not ignored. The marker still drives the 7/14-day clock. const repliesSince = labeledAt && labeledAt < appliedAt ? labeledAt : appliedAt; + // This round already ended in an automatic close and the issue was reopened + // since (e.g. label cleanup failed after closing). Only finish the cleanup; + // a new label application starts a fresh round. + if (events.some(event => event.event === "closed" && event.actor?.type === "Bot" && new Date(event.created_at) >= appliedAt)) { + await removeFeedbackLabel(issue_number); + continue; + } + const authorReplied = comments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince @@ -166,7 +187,7 @@ jobs: if (current.state !== "open" || !hasLabel(current, "needs-author-feedback") || hasLabel(current, "keep")) continue; // Skip if the label was removed and reapplied (a new feedback round) since it was inspected. - const currentLabeledAt = await getLatestLabeledAt(issue_number); + const currentLabeledAt = getLatestLabeledAt(await getEvents(issue_number)); if (String(currentLabeledAt) !== String(labeledAt)) continue; const recentComments = await github.paginate(github.rest.issues.listComments, { @@ -191,6 +212,7 @@ jobs: ].join("\n") }); + const closeRequestedAt = new Date(); await github.rest.issues.update({ owner, repo, @@ -199,23 +221,14 @@ jobs: state_reason: "not_planned" }); - // Remove the label only after a successful close so a failed close - // is retried on the next run, and a later manual reopen does not - // immediately re-close the issue from the old `applied` marker. - // A fresh label application restarts the 14-day clock. - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number, - name: "needs-author-feedback" - }); - } catch (error) { - if (error.status !== 404) throw error; - } + // Remove the label only after a successful close so a failed close is + // retried on the next run. If this removal fails, the completed-round + // check above cleans it up after any reopen instead of re-closing. + await removeFeedbackLabel(issue_number); // An author reply that landed between the final check and the close - // may have been handled while the issue was still open; reopen it. + // may have been handled while the issue was still open; reopen it, but + // only if the current closure is still this run's own automatic close. const lateComments = await github.paginate(github.rest.issues.listComments, { owner, repo, @@ -224,6 +237,16 @@ jobs: per_page: 100 }); if (lateComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince)) { + const { data: after } = await github.rest.issues.get({ owner, repo, issue_number }); + const afterEvents = await getEvents(issue_number); + const lastClosed = latestEvent(afterEvents, "closed"); + const lastReopened = latestEvent(afterEvents, "reopened"); + const ownClose = after.state === "closed" && + lastClosed?.actor?.type === "Bot" && + new Date(lastClosed.created_at) >= new Date(closeRequestedAt.getTime() - 60 * 1000) && + (!lastReopened || new Date(lastReopened.created_at) < new Date(lastClosed.created_at)); + if (!ownClose) continue; + await github.rest.issues.update({ owner, repo,