Skip to content

Commit 3f0c24a

Browse files
committed
chore(github): add follow-up workflow
1 parent 79dbdc8 commit 3f0c24a

File tree

1 file changed

+194
-0
lines changed

1 file changed

+194
-0
lines changed

.github/workflows/follow-up.yml

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
name: follow-up
2+
3+
on:
4+
schedule:
5+
- cron: "23 3 * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
jobs:
13+
scan:
14+
runs-on: ubuntu-latest
15+
16+
env:
17+
DAYS_WAIT: "7"
18+
FLAG_LABEL: "follow up"
19+
EXEMPT_LABELS: "release,stale,p0-critical,p1-high,p2-medium,p3-low"
20+
POST_COMMENT: "true"
21+
DRY_RUN: "true"
22+
23+
steps:
24+
- uses: actions/github-script@v8
25+
with:
26+
script: |
27+
const DAYS_WAIT = parseInt(process.env.DAYS_WAIT || "7", 10);
28+
const FLAG_LABEL = (process.env.FLAG_LABEL || "follow up").trim();
29+
const EXEMPT_LABELS = (process.env.EXEMPT_LABELS || "")
30+
.split(",").map(s => s.trim().toLowerCase()).filter(Boolean);
31+
const POST_COMMENT = (process.env.POST_COMMENT || "true").toLowerCase() === "true";
32+
const DRY_RUN = (process.env.DRY_RUN || "false").toLowerCase() === "true";
33+
34+
const OWNER_TYPES = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
35+
const now = new Date();
36+
37+
async function ensureLabel(number) {
38+
try {
39+
if (DRY_RUN) { core.info(`[DRY] Would add label '${FLAG_LABEL}' to #${number}`); return; }
40+
await github.rest.issues.addLabels({
41+
owner: context.repo.owner,
42+
repo: context.repo.repo,
43+
issue_number: number,
44+
labels: [FLAG_LABEL],
45+
});
46+
} catch (e) {
47+
if (e.status !== 422) throw e; // 422 = already has label
48+
}
49+
}
50+
51+
async function removeLabelIfPresent(number) {
52+
try {
53+
if (DRY_RUN) { core.info(`[DRY] Would remove label '${FLAG_LABEL}' from #${number}`); return; }
54+
await github.rest.issues.removeLabel({
55+
owner: context.repo.owner,
56+
repo: context.repo.repo,
57+
issue_number: number,
58+
name: FLAG_LABEL,
59+
});
60+
} catch (e) {
61+
if (e.status !== 404) throw e; // 404 = label not present
62+
}
63+
}
64+
65+
async function postNudgeComment(number, issueAuthor, lastMaintainerAt) {
66+
if (!POST_COMMENT) return;
67+
const days = DAYS_WAIT;
68+
const body =
69+
`Hi @${issueAuthor}! A maintainer responded on ${new Date(lastMaintainerAt).toISOString().slice(0,10)}.\n\n` +
70+
`If the answer solved your problem, please consider closing this issue. ` +
71+
`Otherwise, feel free to reply with more details so we can help.\n\n` +
72+
`_(Label: \`${FLAG_LABEL}\` — added after ${days} day${days === 1 ? '' : 's'} without a follow-up from the author.)_`;
73+
74+
if (DRY_RUN) { core.info(`[DRY] Would comment on #${number}: ${body}`); return; }
75+
await github.rest.issues.createComment({
76+
owner: context.repo.owner,
77+
repo: context.repo.repo,
78+
issue_number: number,
79+
body,
80+
});
81+
}
82+
83+
const issues = await github.paginate(
84+
github.rest.issues.listForRepo,
85+
{
86+
owner: context.repo.owner,
87+
repo: context.repo.repo,
88+
state: "open",
89+
per_page: 100,
90+
}
91+
);
92+
93+
let flagged = 0, unflagged = 0, skipped = 0;
94+
95+
for (const issue of issues) {
96+
if (issue.pull_request) { skipped++; continue; }
97+
98+
const number = issue.number;
99+
const author = issue.user?.login;
100+
const authorAssoc = String(issue.author_association || "").toUpperCase();
101+
const issueLabels = (issue.labels || []).map(l => (typeof l === 'string' ? l : l.name).toLowerCase());
102+
103+
// Skip issues created by a maintainer
104+
if (OWNER_TYPES.has(authorAssoc)) {
105+
if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {
106+
await removeLabelIfPresent(number);
107+
unflagged++;
108+
} else {
109+
skipped++;
110+
}
111+
continue;
112+
}
113+
114+
// Skip exempt labels
115+
if (issueLabels.some(l => EXEMPT_LABELS.includes(l))) { skipped++; continue; }
116+
117+
// Fetch comments
118+
const comments = await github.paginate(
119+
github.rest.issues.listComments,
120+
{
121+
owner: context.repo.owner,
122+
repo: context.repo.repo,
123+
issue_number: number,
124+
per_page: 100,
125+
}
126+
);
127+
128+
if (comments.length === 0) {
129+
if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {
130+
await removeLabelIfPresent(number);
131+
unflagged++;
132+
} else {
133+
skipped++;
134+
}
135+
continue;
136+
}
137+
138+
const lastComment = comments[comments.length - 1];
139+
140+
// If last comment is by issue author, remove label
141+
if (lastComment.user?.login === author) {
142+
if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {
143+
await removeLabelIfPresent(number);
144+
unflagged++;
145+
} else {
146+
skipped++;
147+
}
148+
continue;
149+
}
150+
151+
// Find last maintainer comment
152+
const maintainerComments = comments.filter(c =>
153+
OWNER_TYPES.has(String(c.author_association).toUpperCase())
154+
);
155+
if (maintainerComments.length === 0) {
156+
if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {
157+
await removeLabelIfPresent(number);
158+
unflagged++;
159+
} else {
160+
skipped++;
161+
}
162+
continue;
163+
}
164+
165+
const lastMaintainer = maintainerComments[maintainerComments.length - 1];
166+
const lastMaintainerAt = new Date(lastMaintainer.created_at);
167+
168+
// Did the author reply after that?
169+
const authorFollowUp = comments.some(c =>
170+
c.user?.login === author && new Date(c.created_at) > lastMaintainerAt
171+
);
172+
173+
if (authorFollowUp) {
174+
if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {
175+
await removeLabelIfPresent(number);
176+
unflagged++;
177+
} else {
178+
skipped++;
179+
}
180+
continue;
181+
}
182+
183+
// No author follow-up since maintainer reply
184+
const elapsedDays = Math.floor((now - lastMaintainerAt) / (1000 * 60 * 60 * 24));
185+
if (elapsedDays >= DAYS_WAIT) {
186+
await ensureLabel(number);
187+
await postNudgeComment(number, author, lastMaintainerAt);
188+
flagged++;
189+
} else {
190+
skipped++;
191+
}
192+
}
193+
194+
core.info(`Done. Flagged: ${flagged}, Unflagged: ${unflagged}, Skipped: ${skipped}`);

0 commit comments

Comments
 (0)