Skip to content
258 changes: 258 additions & 0 deletions .github/workflows/needs-author-feedback-followup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
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("<!-- needs-author-feedback:applied -->")
);

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);
}

// 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" &&
comment.body?.includes("<!-- needs-author-feedback:reminder -->") &&
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 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 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.",
"",
"<!-- needs-author-feedback:applied -->"
].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;

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 = 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,
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.",
"",
"<!-- needs-author-feedback:closed -->"
].join("\n")
});

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,
repo,
issue_number,
name: "needs-author-feedback"
});
} catch (error) {
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: 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;
}

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.",
"",
"<!-- needs-author-feedback:reminder -->"
].join("\n")
});
}
}
Loading
Loading