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

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("<!-- 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 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.",
"",
"<!-- 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;

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