From 380561b6bbe8fbcfa2581eaef916a378422ef158 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:38:18 -0700 Subject: [PATCH 1/2] Align issue and PR auto-close automation across Azure Monitor repositories Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../needs-author-feedback-followup.yml | 101 ++++++++++++++++-- .github/workflows/needs-author-feedback.yml | 79 +++++++++++++- .github/workflows/stale.yml | 53 +++++++++ 3 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index 8144753f7..caf34c0ce 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" && @@ -56,6 +83,10 @@ jobs: for (const issue of issues) { 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; @@ -68,9 +99,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 @@ -90,10 +125,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) { @@ -121,6 +159,25 @@ jobs: const ageInDays = daysBetween(appliedAt, now); if (ageInDays >= 14) { + // 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; + + // 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, + issue_number, + since: repliesSince.toISOString(), + per_page: 100 + }); + if (recentComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince)) continue; + await github.rest.issues.createComment({ owner, repo, @@ -134,11 +191,18 @@ jobs: ].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. + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + 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, @@ -150,12 +214,29 @@ jobs: if (error.status !== 404) throw error; } - await github.rest.issues.update({ + // 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, - state: "closed" + since: repliesSince.toISOString(), + per_page: 100 }); + if (lateComments.some(comment => comment.user?.login === author && new Date(comment.created_at) > repliesSince)) { + 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 b714d3845..187086bd4 100644 --- a/.github/workflows/needs-author-feedback.yml +++ b/.github/workflows/needs-author-feedback.yml @@ -55,13 +55,90 @@ jobs: const comment = context.payload.comment; if (issue.pull_request) return; - if (!issue.labels.some(label => label.name === "needs-author-feedback")) return; + if (issue.user?.type === "Bot") return; if (comment.user.login !== issue.user.login) return; const owner = context.repo.owner; const repo = context.repo.repo; const issue_number = issue.number; + // 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" || current.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 + }); + // 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) >= cycleStart && + 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; + + 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 (!current.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 new file mode 100644 index 000000000..16758d363 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,53 @@ +# Shared stale/auto-close policy for Azure Monitor repositories. +# 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: + schedule: + - cron: "0 7 * * *" + +permissions: + actions: write + issues: write + pull-requests: write + +concurrency: + group: stale-handler + cancel-in-progress: false + +jobs: + # 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: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + 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 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" + # 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,waiting for backend fix,waiting - CDN deployment,fixed - waiting release" + exempt-all-milestones: true + exempt-all-assignees: false + exempt-draft-pr: false + ascending: true + operations-per-run: 25 From 075f3a5504b6a2504175c33c1c0b2b3792edefea Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:48:02 -0700 Subject: [PATCH 2/2] 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,