Skip to content

Commit 5da428b

Browse files
authored
feat: enforce maximum of 5 completed Good First Issues per contributor (#1286)
Signed-off-by: Rob Walworth <robert.walworth@swirldslabs.com>
1 parent a1a0ba4 commit 5da428b

File tree

3 files changed

+178
-0
lines changed

3 files changed

+178
-0
lines changed

.github/scripts/commands/assign-comments.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ const { MAINTAINER_TEAM, LABELS, ISSUE_STATE } = require('../helpers');
1414
*/
1515
const MAX_OPEN_ASSIGNMENTS = 2;
1616

17+
/**
18+
* Maximum number of Good First Issues a contributor may complete before being
19+
* redirected to Beginner and higher-level issues. Enforced by handleAssign in assign.js.
20+
* @type {number}
21+
*/
22+
const MAX_GFI_COMPLETIONS = 5;
23+
1724
/**
1825
* Skill-level prerequisite map. Each key is a LABELS skill-level constant.
1926
* - requiredLabel: the prerequisite skill label the user must have completed, or null if none.
@@ -323,6 +330,32 @@ function buildLabelUpdateFailureComment(username, error) {
323330
].join('\n');
324331
}
325332

333+
/**
334+
* Builds the comment posted when a contributor has already completed the maximum
335+
* number of Good First Issues (MAX_GFI_COMPLETIONS). Rejects the assignment
336+
* warmly and redirects them toward Beginner and higher-level issues.
337+
*
338+
* @param {string} requesterUsername - The GitHub username who commented /assign.
339+
* @param {number} completedCount - How many Good First Issues the user has completed.
340+
* @param {string} owner - Repository owner (for the search URL).
341+
* @param {string} repo - Repository name (for the search URL).
342+
* @returns {string} The formatted Markdown comment body.
343+
*/
344+
function buildGfiLimitExceededComment(requesterUsername, completedCount, owner, repo) {
345+
const searchQuery = `is:issue is:open no:assignee label:"${LABELS.BEGINNER}" label:"${LABELS.READY_FOR_DEV}"`;
346+
const searchUrl = buildIssuesSearchUrl(owner, repo, searchQuery);
347+
return [
348+
`👋 Hi @${requesterUsername}! You've completed **${completedCount} Good First Issues** — that's a fantastic achievement, and it shows you know the workflow inside and out. 🎉`,
349+
'',
350+
'Good First Issues are designed to help new contributors get comfortable with the process, and you\'ve clearly mastered it. We believe you\'re more than ready to take on bigger challenges!',
351+
'',
352+
'👉 **Find Beginner and higher issues to work on:**',
353+
`[Browse available Beginner issues](${searchUrl})`,
354+
'',
355+
'Come take on something more challenging — we\'re excited to see what you\'ll build next! 🚀',
356+
].join('\n');
357+
}
358+
326359
/**
327360
* Builds the comment posted when the addAssignees API call itself fails.
328361
* Tags the maintainer team to manually assign the user and includes the error details.
@@ -343,6 +376,7 @@ function buildAssignmentFailureComment(requesterUsername, error) {
343376

344377
module.exports = {
345378
MAX_OPEN_ASSIGNMENTS,
379+
MAX_GFI_COMPLETIONS,
346380
SKILL_HIERARCHY,
347381
SKILL_PREREQUISITES,
348382
buildWelcomeComment,
@@ -351,6 +385,7 @@ module.exports = {
351385
buildPrerequisiteNotMetComment,
352386
buildNoSkillLevelComment,
353387
buildAssignmentLimitExceededComment,
388+
buildGfiLimitExceededComment,
354389
buildApiErrorComment,
355390
buildLabelUpdateFailureComment,
356391
buildAssignmentFailureComment,

.github/scripts/commands/assign.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const {
2222

2323
const {
2424
MAX_OPEN_ASSIGNMENTS,
25+
MAX_GFI_COMPLETIONS,
2526
SKILL_HIERARCHY,
2627
SKILL_PREREQUISITES,
2728
buildWelcomeComment,
@@ -30,6 +31,7 @@ const {
3031
buildPrerequisiteNotMetComment,
3132
buildNoSkillLevelComment,
3233
buildAssignmentLimitExceededComment,
34+
buildGfiLimitExceededComment,
3335
buildApiErrorComment,
3436
buildLabelUpdateFailureComment,
3537
buildAssignmentFailureComment,
@@ -221,6 +223,42 @@ async function updateLabels(botContext, requesterUsername) {
221223
}
222224
}
223225

226+
/**
227+
* Checks whether the requester has reached the GFI completion cap. Only applies
228+
* when skillLevel is LABELS.GOOD_FIRST_ISSUE. Posts an encouraging comment and
229+
* returns false when the cap is reached; returns true otherwise.
230+
*
231+
* @param {object} botContext - Bot context from buildBotContext.
232+
* @param {string} skillLevel - The skill-level label on the issue (a LABELS constant).
233+
* @param {string} requesterUsername - GitHub username requesting assignment.
234+
* @returns {Promise<boolean>} True if under the cap or not a GFI; false otherwise.
235+
*/
236+
async function enforceGfiCompletionLimit(botContext, skillLevel, requesterUsername) {
237+
if (skillLevel !== LABELS.GOOD_FIRST_ISSUE) return true;
238+
239+
const completedCount = await countAssignedIssues(
240+
botContext.github, botContext.owner, botContext.repo,
241+
requesterUsername, ISSUE_STATE.CLOSED, LABELS.GOOD_FIRST_ISSUE
242+
);
243+
if (completedCount === null) {
244+
logger.log('Exit: could not verify GFI completion count due to API error');
245+
await postComment(botContext, buildApiErrorComment(requesterUsername));
246+
logger.log('Posted API error comment, tagged maintainers');
247+
return false;
248+
}
249+
if (completedCount >= MAX_GFI_COMPLETIONS) {
250+
logger.log('Exit: contributor has reached GFI completion cap', {
251+
maxAllowed: MAX_GFI_COMPLETIONS, completedCount,
252+
});
253+
await postComment(botContext,
254+
buildGfiLimitExceededComment(requesterUsername, completedCount, botContext.owner, botContext.repo));
255+
logger.log('Posted GFI-limit-exceeded comment');
256+
return false;
257+
}
258+
logger.log('GFI completion count OK:', { maxAllowed: MAX_GFI_COMPLETIONS, completedCount });
259+
return true;
260+
}
261+
224262
/**
225263
* Validates that the issue is in a state that allows assignment. Checks three
226264
* gates in order: no existing assignees, "status: ready for dev" label present,
@@ -327,6 +365,7 @@ async function assignAndFinalize(botContext, requesterUsername, skillLevel) {
327365
* 4. No skill-level label? -> no-skill-level comment (tags maintainers).
328366
* 5. Open-assignment count API error? -> API-error comment (tags maintainers).
329367
* 6. At or above MAX_OPEN_ASSIGNMENTS? -> limit-exceeded comment.
368+
* 6b. GFI cap reached (skill: good first issue only)? -> GFI-limit-exceeded comment.
330369
* 7. Skill prerequisites not met? -> prerequisite-not-met comment.
331370
* 8. Assignment API failure? -> assignment-failure comment (tags maintainers).
332371
*
@@ -349,6 +388,9 @@ async function handleAssign(botContext) {
349388
const withinLimit = await enforceAssignmentLimit(botContext, requesterUsername);
350389
if (!withinLimit) return;
351390

391+
const withinGfiCap = await enforceGfiCompletionLimit(botContext, skillLevel, requesterUsername);
392+
if (!withinGfiCap) return;
393+
352394
const prereqsPassed = await checkPrerequisites(botContext, skillLevel, requesterUsername);
353395
if (!prereqsPassed) return;
354396

.github/scripts/tests/test-assign-bot.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,107 @@ Good luck! 🚀`,
352352
],
353353
},
354354

355+
// ---------------------------------------------------------------------------
356+
// GFI COMPLETION CAP TESTS (3 tests)
357+
// Gate added in enforceGfiCompletionLimit
358+
// ---------------------------------------------------------------------------
359+
360+
{
361+
name: 'GFI Cap - Exactly At Limit (5 Completed)',
362+
description: 'Contributor with 5 completed GFIs is rejected with encouraging redirect',
363+
context: {
364+
eventName: 'issue_comment',
365+
payload: {
366+
issue: {
367+
number: 300,
368+
assignees: [],
369+
labels: [
370+
{ name: 'status: ready for dev' },
371+
{ name: 'skill: good first issue' },
372+
],
373+
},
374+
comment: { id: 3001, body: '/assign', user: { login: 'veteran-gfi-user', type: 'User' } },
375+
},
376+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-cpp' },
377+
},
378+
githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 5 } },
379+
expectedAssignee: null,
380+
expectedComments: [
381+
`👋 Hi @veteran-gfi-user! You've completed **5 Good First Issues** — that's a fantastic achievement, and it shows you know the workflow inside and out. 🎉
382+
383+
Good First Issues are designed to help new contributors get comfortable with the process, and you've clearly mastered it. We believe you're more than ready to take on bigger challenges!
384+
385+
👉 **Find Beginner and higher issues to work on:**
386+
[Browse available Beginner issues](https://github.com/hiero-ledger/hiero-sdk-cpp/issues?q=is%3Aissue%20is%3Aopen%20no%3Aassignee%20label%3A%22skill%3A%20beginner%22%20label%3A%22status%3A%20ready%20for%20dev%22)
387+
388+
Come take on something more challenging — we're excited to see what you'll build next! 🚀`,
389+
],
390+
},
391+
392+
{
393+
name: 'GFI Cap - Below Limit (4 Completed)',
394+
description: 'Contributor with 4 completed GFIs is still allowed to take another GFI',
395+
context: {
396+
eventName: 'issue_comment',
397+
payload: {
398+
issue: {
399+
number: 301,
400+
assignees: [],
401+
labels: [
402+
{ name: 'status: ready for dev' },
403+
{ name: 'skill: good first issue' },
404+
],
405+
},
406+
comment: { id: 3002, body: '/assign', user: { login: 'almost-capped-user', type: 'User' } },
407+
},
408+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-cpp' },
409+
},
410+
githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 4 } },
411+
expectedAssignee: 'almost-capped-user',
412+
expectedComments: [
413+
`👋 Hi @almost-capped-user, welcome to the Hiero C++ SDK community! Thank you for choosing to contribute — we're thrilled to have you here! 🎉
414+
415+
You've been assigned this **Good First Issue**, and the **Good First Issue Support Team** (@hiero-ledger/hiero-sdk-good-first-issue-support) is ready to help you succeed.
416+
417+
The issue description above has everything you need: implementation steps, contribution workflow, and links to guides. If anything is unclear, just ask — we're happy to help.
418+
419+
If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the community pool.
420+
421+
Good luck, and welcome aboard! 🚀`,
422+
],
423+
},
424+
425+
{
426+
name: 'GFI Cap - Does Not Apply to Beginner Issues',
427+
description: 'Contributor with 5 completed GFIs can still take a Beginner issue',
428+
context: {
429+
eventName: 'issue_comment',
430+
payload: {
431+
issue: {
432+
number: 302,
433+
assignees: [],
434+
labels: [
435+
{ name: 'status: ready for dev' },
436+
{ name: 'skill: beginner' },
437+
],
438+
},
439+
comment: { id: 3003, body: '/assign', user: { login: 'gfi-graduated-user', type: 'User' } },
440+
},
441+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-cpp' },
442+
},
443+
githubOptions: { completedIssueCounts: { [LABELS.GOOD_FIRST_ISSUE]: 5 } },
444+
expectedAssignee: 'gfi-graduated-user',
445+
expectedComments: [
446+
`👋 Hi @gfi-graduated-user, thanks for continuing to contribute to the Hiero C++ SDK! You've been assigned this **Beginner** issue. 🙌
447+
448+
If this task involves any design decisions or you'd like early feedback, feel free to share your plan here before diving into the code.
449+
450+
If you realize you cannot complete this issue, simply comment \`/unassign\` to return it to the pool.
451+
452+
Good luck! 🚀`,
453+
],
454+
},
455+
355456
// ---------------------------------------------------------------------------
356457
// VALIDATION FAILURES (9 tests)
357458
// Bot rejects assignment with helpful message

0 commit comments

Comments
 (0)