diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml index 8144753f7..0ca0b2f2f 100644 --- a/.github/workflows/needs-author-feedback-followup.yml +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -38,6 +38,45 @@ jobs: return matching[0]; } + function hasLabel(item, name) { + return (item.labels || []).some(label => (typeof label === "string" ? label : label.name) === name); + } + + async function getEvents(issue_number) { + return github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100 + }); + } + + 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]; + } + + // 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) { return comments.some(comment => comment.user?.type === "Bot" && @@ -56,6 +95,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 +111,14 @@ jobs: }); const latestAppliedComment = getLatestAppliedMarker(comments); - if (!latestAppliedComment) { + const events = await getEvents(issue_number); + const labeledAt = getLatestLabeledAt(events); + 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 +138,21 @@ 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; + + // 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) > appliedAt + new Date(comment.created_at) > repliesSince ); if (authorReplied) { @@ -121,6 +180,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 = getLatestLabeledAt(await getEvents(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,29 +212,55 @@ 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. - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number, - name: "needs-author-feedback" - }); - } catch (error) { - if (error.status !== 404) throw error; - } - + const closeRequestedAt = new Date(); await github.rest.issues.update({ owner, repo, issue_number, - state: "closed" + state: "closed", + state_reason: "not_planned" }); + // 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, 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, + issue_number, + since: repliesSince.toISOString(), + 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, + 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