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