-
Notifications
You must be signed in to change notification settings - Fork 287
Expand file tree
/
Copy pathbot-beginner-assign-on-comment.js
More file actions
404 lines (343 loc) · 13.5 KB
/
Copy pathbot-beginner-assign-on-comment.js
File metadata and controls
404 lines (343 loc) · 13.5 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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
/*
==============================================================================
Executes When:
- Triggered by GitHub Actions workflow on event: 'issue_comment' (created).
- Target: Issues specifically labeled with "beginner".
Goal:
It acts as an automated onboarding assistant for "beginner" issues. It allows
contributors to self-assign using a command and nudges new contributors who
express interest but forget to assign themselves, while preventing spam.
------------------------------------------------------------------------------
Flow: Basic Idea
1. Listens for comments on issues.
2. Ignores Pull Requests, Bots, and issues missing the "beginner" label.
3. Detects if the user typed "/assign".
- If YES: Assigns the user to the issue (if currently unassigned).
- If NO: Checks if the user is an external contributor expressing interest.
If so, it replies with instructions on how to use the assign command.
------------------------------------------------------------------------------
Flow: Detailed Technical Steps
1️⃣ Validation & filtering
- Checks payload to ensure it is an Issue Comment (not a PR).
- Checks if the commenter is a BOT (e.g., github-actions). If so, exits.
- Checks if the issue has the specific label "beginner". If not, exits.
2️⃣ Collaborator Check (isRepoCollaborator)
- Action: Checks if the user is a repository collaborator.
- Logic:
* Collaborator (204) -> Treated as Team Member (Bot ignores them).
* Non-Collaborator (404) -> Treated as External Contributor (Bot helps them).
3️⃣ Logic Branch A: The "/assign" Command
- Trigger: User comment matches regex /(^|\s)\/assign(\s|$)/i.
- Check: Is the issue already assigned?
* Yes -> Alert the user that it is taken.
* No -> API Call: Add commenter to 'assignees'.
4️⃣ Logic Branch B: The Helper Reminder
- Trigger: Generic comment (e.g., "I want to work on this").
- Condition 1: Issue must be unassigned.
- Condition 2: Commenter must NOT be a Repo Collaborator.
- Condition 3: Duplicate Check.
* Scans previous comments for a hidden marker: "<!-- beginner assign reminder -->".
* If found -> Exits to avoid spamming the thread.
- Action: Posts a comment with the hidden marker and instructions.
------------------------------------------------------------------------------
Parameters:
- { github, context }: Standard objects provided by 'actions/github-script'.
==============================================================================
*/
const fs = require("fs");
const { BEGINNER_LABEL } = require('./shared/labels.js');
const SPAM_LIST_PATH = ".github/spam-list.txt";
const REQUIRED_GFI_COUNT = 1;
const GFI_LABEL = 'Good First Issue';
const BEGINNER_GUARD_MARKER = '<!-- beginner-gfi-guard -->';
function isSafeSearchToken(value) {
return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value);
}
async function countCompletedGfiIssues(github, owner, repo, username) {
if (
!isSafeSearchToken(owner) ||
!isSafeSearchToken(repo) ||
!isSafeSearchToken(username)
) {
return null;
}
const searchQuery = [
`repo:${owner}/${repo}`,
`label:"${GFI_LABEL}"`,
'is:issue',
'is:closed',
`assignee:${username}`,
].join(' ');
const result = await github.graphql(
`
query ($searchQuery: String!) {
search(type: ISSUE, query: $searchQuery) {
issueCount
}
}
`,
{ searchQuery }
);
return result?.search?.issueCount ?? 0;
}
module.exports = async ({ github, context }) => {
try {
const { payload } = context;
const issue = payload.issue;
const comment = payload.comment;
const repo = payload.repository;
// 1. Basic Validation
if (!issue || !comment || !repo || issue.pull_request) {
console.log("[Beginner Bot] Invalid payload or PR comment. Exiting.");
return;
}
// 1.1 Bot Check (Fix 2: Defensive Check)
if (comment.user?.type === "Bot") {
console.log(`[Beginner Bot] Commenter @${comment.user.login} is a bot. Exiting.`);
return;
}
// 2. Label Check (Fix 2: Defensive Check)
const beginnerLabel = BEGINNER_LABEL.toLowerCase();
const hasBeginnerLabel = Array.isArray(issue.labels) && issue.labels.some((label) => label.name?.toLowerCase() === beginnerLabel);
if (!hasBeginnerLabel) {
console.log(`[Beginner Bot] Issue #${issue.number} does not have '${BEGINNER_LABEL}' label. Exiting.`);
return;
}
// 3. Collaborator Check Helper
async function isRepoCollaborator(username) {
try {
if (username === repo.owner.login) {
console.log(`[Beginner Bot] User @${username} is the repo owner.`);
return true;
}
await github.rest.repos.checkCollaborator({
owner: repo.owner.login,
repo: repo.name,
username: username,
});
console.log(`[Beginner Bot] User @${username} is a confirmed repo collaborator.`);
return true;
} catch (error) {
if (error.status === 404) {
console.log(`[Beginner Bot] User @${username} is NOT a collaborator (External Contributor).`);
return false;
}
console.log(`[Beginner Bot] Error checking collaborator status for @${username}: ${error.message}`);
return false;
}
}
// NEW: Spam + Assignment Limit Helpers (Layered In)
function isSpamUser(username) {
if (!fs.existsSync(SPAM_LIST_PATH)) return false;
const list = fs.readFileSync(SPAM_LIST_PATH, "utf8")
.split("\n")
.map(l => l.trim())
.filter(l => l && !l.startsWith("#"));
return list.includes(username);
}
async function getOpenAssignments(username) {
const issues = await github.paginate(
github.rest.issues.listForRepo,
{
owner: repo.owner.login,
repo: repo.name,
assignee: username,
state: "open",
per_page: 100,
}
);
return issues.length;
}
const commenter = comment.user.login;
// Fix 3: Validate comment body
if (!comment.body) {
console.log("[Beginner Bot] Comment body is empty. Exiting.");
return;
}
const commentBody = comment.body.toLowerCase();
const isAssignCommand = /(^|\s)\/assign(\s|$)/i.test(commentBody);
// 4. Logic Branch
if (isAssignCommand) {
const completedGfiCount = await countCompletedGfiIssues(
github,
repo.owner.login,
repo.name,
commenter
);
console.log("[Beginner Bot] Completed GFI count:",{
commenter,
completedGfiCount,
})
if (completedGfiCount === null) {
console.log("[Beginner Bot] Skipping GFI guard due to API error.");
} else if (completedGfiCount < REQUIRED_GFI_COUNT) {
let allComments = [];
try {
allComments = await github.paginate(
github.rest.issues.listComments,
{
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
per_page: 100,
}
);
} catch (error) {
console.error("[Beginner Bot] Failed to fetch comments for GFI guard:", {
issue: issue.number,
commenter,
message: error.message,
});
return;
}
const guardAlreadyPosted = allComments.some((c) =>
c.body?.includes(BEGINNER_GUARD_MARKER)
);
if (!guardAlreadyPosted) {
try{
await github.rest.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
body: `${BEGINNER_GUARD_MARKER}
👋 Hi @${commenter}! Thanks for your interest in contributing 💡
Before taking on a **beginner** issue, we ask contributors to complete at least one **Good First Issue** to get familiar with the workflow.
👉 [Find a Good First Issue here](https://github.com/${repo.owner.login}/${repo.name}/issues?q=is%3Aissue+is%3Aopen+label%3A%22Good+First+Issue%22+no%3Aassignee)
Please try a GFI first, then come back — we’ll be happy to assign this! 😊`,
});
console.log("[Beginner Bot] GFI guard comment posted.");
} catch(error){
console.error("[Beginner Bot] Failed to post GFI guard comment:",{
issue: issue.number,
commenter,
message: error.message,
});
}
}
return;
}
// --- ASSIGNMENT LOGIC ---
if (issue.assignees && issue.assignees.length > 0) {
try{
const currentAssignee = issue.assignees[0]?.login ?? "another contributor";
await github.rest.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
body: `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to @${currentAssignee}, but we'd love your help on another one. You can find more "${BEGINNER_LABEL}" issues [here](https://github.com/${repo.owner.login}/${repo.name}/issues?q=is%3Aissue+is%3Aopen+label%3A%22${encodeURIComponent(BEGINNER_LABEL)}%22+no%3Aassignee).`,
});
} catch (error) {
console.error("[Beginner Bot] Failed to post already-assigned message:", {
issue: issue.number,
commenter,
message: error.message,
});
}
return;
}
// Block spam users from beginner issues
const spamUser = isSpamUser(commenter);
if (spamUser) {
console.log(`[Beginner Bot] Spam user @${commenter} attempted to assign to beginner issue. Blocked.`);
try {
await github.rest.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
body: `Hi @${commenter}, your account is currently restricted to **Good First Issues**. Please complete a Good First Issue or contact a maintainer to have restrictions reviewed.`,
});
} catch (error) {
console.error(`[Beginner Bot] Failed to post spam restriction message: ${error.message}`);
}
return;
}
// Enforce Assignment Limits
const openCount = await getOpenAssignments(commenter);
const maxAllowed = 2;
console.log("[Beginner Bot] Limit check:", {
commenter,
openCount,
spamUser,
maxAllowed,
});
if (openCount >= maxAllowed) {
const message = `👋 Hi @${commenter}, you already have **2 open assignments**. Please finish one before requesting another.`;
try {
await github.rest.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
body: message,
});
} catch (error) {
console.error(`[Beginner Bot] Failed to post limit warning: ${error.message}`);
}
return;
}
console.log(`[Beginner Bot] Assigning issue #${issue.number} to @${commenter}...`);
// Fix 4: Granular Try/Catch for Assign API
try {
await github.rest.issues.addAssignees({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
assignees: [commenter],
});
console.log(`[Beginner Bot] Successfully assigned.`);
} catch (error) {
console.error(`[Beginner Bot] Failed to assign issue: ${error.message}`);
}
} else {
// --- REMINDER LOGIC ---
if (issue.assignees && issue.assignees.length > 0) {
console.log(`[Beginner Bot] Issue #${issue.number} is already assigned. Skipping reminder.`);
return;
}
if (await isRepoCollaborator(commenter)) {
console.log(`[Beginner Bot] Commenter @${commenter} is a repo collaborator. Skipping reminder.`);
return;
}
// Fix 5: Updated Marker Text
const REMINDER_MARKER = "<!-- beginner assign reminder -->";
// FIX 6: Granular Try/Catch for List Comments API
let comments;
try {
const { data } = await github.rest.issues.listComments({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
});
comments = data;
} catch (error) {
console.error(`[Beginner Bot] Failed to list comments: ${error.message}`);
return; // Exit gracefully if we can't check for duplicates
}
if (comments.some((c) => c.body.includes(REMINDER_MARKER))) {
console.log("[Beginner Bot] Reminder already exists on this issue. Skipping.");
return;
}
console.log(`[Beginner Bot] Posting help reminder for @${commenter}...`);
const reminderBody = `${REMINDER_MARKER}\n👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`;
// FIX 6: Granular Try/Catch for Create Comment API
try {
await github.rest.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: issue.number,
body: reminderBody,
});
console.log("[Beginner Bot] Reminder posted successfully.");
} catch (error) {
console.error(`[Beginner Bot] Failed to post reminder: ${error.message}`);
}
}
} catch (error) {
// Fix 1: Top-level error handling
console.error("[Beginner Bot] Unexpected error:", {
message: error.message,
status: error.status,
issue: context.payload?.issue?.number,
comment: context.payload?.comment?.id
});
}
};