From 1022286347b84551dbb98092182f4e9c6bea06d9 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:40:32 -0700 Subject: [PATCH 1/7] 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.yml | 85 +++++++-- 3 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/needs-author-feedback-followup.yml create mode 100644 .github/workflows/needs-author-feedback.yml diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml new file mode 100644 index 00000000..8144753f --- /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 00000000..b714d384 --- /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.yml b/.github/workflows/stale.yml index dcd6139e..92efa8ab 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,31 +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: + # 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. stale: + needs: stale-wontfix + if: ${{ !cancelled() }} 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' + 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: true \ No newline at end of file + exempt-all-assignees: false + ascending: true + operations-per-run: 15 From 4ad95e2d63ea3c9504de4754dba35f7e1c004923 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:46:45 -0700 Subject: [PATCH 2/7] 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 8144753f..71d70e40 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 92efa8ab..3422476a 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 3b1d2e4c5df1e5dc3fed7b107dc00b96ad6d6102 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:58:11 -0700 Subject: [PATCH 3/7] 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 71d70e40..f52ac1b4 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 b714d384..5f90f908 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 3422476a..16758d36 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 343d1889c6914a4ee260e9f870c9d03c41a464cb Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:07:01 -0700 Subject: [PATCH 4/7] 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 f52ac1b4..e19c7c78 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 5f90f908..92d73898 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 eb31c500ba143a2ddcdb453332bb1a1e0020cdc5 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:16:55 -0700 Subject: [PATCH 5/7] 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 e19c7c78..ee5cc9ba 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 92d73898..2868649c 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 dd753d233e7116dcea79bea6fbffa77205ffff5e Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:31:35 -0700 Subject: [PATCH 6/7] 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 ee5cc9ba..caf34c0c 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 2868649c..b539c3c2 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 38cd73141d8bc8dfe3a5f55e0d334a462a4450b5 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:40:07 -0700 Subject: [PATCH 7/7] 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 b539c3c2..187086bd 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;