Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 124 additions & 20 deletions .github/workflows/needs-author-feedback-followup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Comment thread
hectorhdzg marked this conversation as resolved.
Resolved

// 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"
Comment thread
hectorhdzg marked this conversation as resolved.
Resolved
});
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: "Thanks for the response. Reopening this issue."
});
}

continue;
}

Expand Down
79 changes: 78 additions & 1 deletion .github/workflows/needs-author-feedback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- needs-author-feedback:closed -->") &&
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,
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
@@ -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
Loading