diff --git a/.github/workflows/needs-author-feedback-followup.yml b/.github/workflows/needs-author-feedback-followup.yml new file mode 100644 index 000000000..0ca0b2f2f --- /dev/null +++ b/.github/workflows/needs-author-feedback-followup.yml @@ -0,0 +1,281 @@ +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 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" && + 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; + // `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; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100 + }); + + const latestAppliedComment = getLatestAppliedMarker(comments); + 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 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 + // 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); + // 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) > repliesSince + ); + + 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) { + // 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, + 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") + }); + + const closeRequestedAt = new Date(); + 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. 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; + } + + 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..187086bd4 --- /dev/null +++ b/.github/workflows/needs-author-feedback.yml @@ -0,0 +1,158 @@ +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.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, + 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..16758d363 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,70 +1,53 @@ -name: 'Stale issue handler' +# 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 * * *' + - 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 - 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' + 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: true - - 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 - exempt-all-milestones: false - exempt-all-assignees: false - - stale-wontfix: - 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 - 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' - exempt-all-milestones: false exempt-all-assignees: false + exempt-draft-pr: false + ascending: true + operations-per-run: 25