diff --git a/.github/scripts/bot-assignment-check.sh b/.github/scripts/bot-assignment-check.sh deleted file mode 100644 index 7b1a20b5e..000000000 --- a/.github/scripts/bot-assignment-check.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/bin/bash -set -Eeuo pipefail - -# Validate required env vars -if [ -z "${ASSIGNEE:-}" ] || [ -z "${ISSUE_NUMBER:-}" ] || [ -z "${REPO:-}" ]; then - echo "Error: Missing required environment variables (ASSIGNEE, ISSUE_NUMBER, REPO)." - exit 1 -fi - -echo "Checking assignment rules for user $ASSIGNEE on issue #$ISSUE_NUMBER" - -# Helper functions -get_permission() { - gh api "repos/${REPO}/collaborators/${ASSIGNEE}/permission" --jq '.permission' 2>/dev/null || echo "none" - -} - -is_spam_user() { - local spam_file=".github/spam-list.txt" - - # Check static spam list - if [[ -f "$spam_file" ]]; then - if grep -vE '^\s*#|^\s*$' "$spam_file" | grep -qxF "$ASSIGNEE"; then - return 0 - fi - else - echo "Spam list file not found. Treating as empty." >&2 - fi - return 1 -} - -issue_has_gfi() { - local has - has=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq 'any(.labels[]; .name=="Good First Issue")' || echo "false") - [[ "$has" == "true" ]] -} - -assignments_count() { - gh issue list --repo "${REPO}" --assignee "${ASSIGNEE}" --state open --limit 100 --json number --jq 'length' -} - -remove_assignee() { - gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --remove-assignee "${ASSIGNEE}" -} - -post_comment() { - gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$1" -} - -msg_spam_non_gfi() { - cat < 1 )); then - echo "Spam user limit exceeded (Max 1 allowed). Revoking assignment." - remove_assignee - post_comment "$(msg_spam_limit_exceeded "$COUNT")" - exit 1 - fi - - echo "Spam-listed user assignment valid. User has $COUNT assignment(s)." -else - # Normal users have a limit of 2 open assignments - echo "Current open assignments count: $COUNT" - - if (( COUNT > 2 )); then - echo "Limit exceeded (Max 2 allowed). Revoking assignment." - remove_assignee - post_comment "$(msg_normal_limit_exceeded)" - exit 1 - fi - - echo "Assignment valid. User has $COUNT assignments." -fi diff --git a/.github/scripts/bot-auto-assign-beginner-issues-comment.js b/.github/scripts/bot-auto-assign-beginner-issues-comment.js new file mode 100644 index 000000000..bb67aa3fd --- /dev/null +++ b/.github/scripts/bot-auto-assign-beginner-issues-comment.js @@ -0,0 +1,248 @@ +// +// Auto-assigns a Beginner Issue when a human user comments "/assign". +// Requirement: user must have completed at least one Good First Issue. +// Assumes: other bots continue to enforce max 2 issues at a time, spam guards, etc. +// GFI completion logic is handled by reusable scripts/lib/has-gfi.js +// + +const { hasCompletedGfi } = require('./lib/has-gfi'); + +const BEGINNER_ISSUE_LABEL = 'beginner'; +const ASSIGN_REMINDER_MARKER = ''; + +const UNASSIGNED_GFI_SEARCH_URL = + 'https://github.com/hiero-ledger/hiero-sdk-python/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Good%20First%20Issue%22%20no%3Aassignee'; +const UNASSIGNED_BEGINNER_SEARCH_URL = + 'https://github.com/hiero-ledger/hiero-sdk-python/issues?q=is%3Aissue%20state%3Aopen%20label%3Abeginner%20no%3Aassignee'; + +/// ───────────────────────────────────────────────────────────── +/// HELPERS – BASIC CHECKS +/// ───────────────────────────────────────────────────────────── + +function commentRequestsAssignment(body) { + const matches = + typeof body === 'string' && + /(^|\s)\/assign(\s|$)/i.test(body); + + console.log('[beginner-assign] commentRequestsAssignment:', { + body, + matches, + }); + + return matches; +} + +function issueIsBeginnerIssue(issue) { + const labels = issue?.labels?.map(l => l.name) ?? []; + const isBeginner = labels.includes(BEGINNER_ISSUE_LABEL); + + console.log('[beginner-assign] issueIsBeginnerIssue:', { + labels, + expected: BEGINNER_ISSUE_LABEL, + isBeginner, + }); + + return isBeginner; +} + +function getCurrentAssigneeMention(issue) { + const login = issue?.assignees?.[0]?.login; + return login ? `@${login}` : 'someone'; +} + +/// ───────────────────────────────────────────────────────────── +/// HELPERS – COMMENTS +/// ───────────────────────────────────────────────────────────── + +function commentAlreadyAssigned(username, issue) { + return ( + `Hi @${username} — thanks for your interest in this issue! + +This one is already assigned to ${getCurrentAssigneeMention(issue)}, so I can’t assign it again right now. + +👉 **Take a look at other open Beginner Issues to work on:** +[Browse unassigned Beginner Issues](${UNASSIGNED_BEGINNER_SEARCH_URL}) + +Once you find one you like, just comment \`/assign\` to get started 😊` + ); +} + +function buildAssignReminder(username) { + return `${ASSIGN_REMINDER_MARKER} +👋 Hi @${username}! + +If you’d like to work on this **Beginner Issue**, just comment: + +\`\`\` +/assign +\`\`\` + +and you’ll be automatically assigned.`; +} + +function buildMissingGfiRequirementComment(username) { + return ( + `Hi @${username}! 👋 + +Before working on **Beginner Issues**, we ask contributors to complete **at least one Good First Issue** first. + +👉 **Start here:** +[Browse unassigned Good First Issues](${UNASSIGNED_GFI_SEARCH_URL}) + +Once you’ve completed one, feel free to come back and request this issue if not assigned. 🚀` + ); +} + +/// ───────────────────────────────────────────────────────────── +/// ENTRY POINT +/// ───────────────────────────────────────────────────────────── + +module.exports = async ({ github, context }) => { + try { + const { issue, comment } = context.payload; + const { owner, repo } = context.repo; + + console.log('[beginner-assign] Payload snapshot:', { + issueNumber: issue?.number, + commenter: comment?.user?.login, + commenterType: comment?.user?.type, + commentBody: comment?.body, + assignees: issue?.assignees?.map(a => a.login), + }); + + // Basic validation + if (!issue?.number) { + console.log('[beginner-assign] Exit: missing issue number'); + return; + } + + if (!comment?.body) { + console.log('[beginner-assign] Exit: missing comment body'); + return; + } + + if (!comment?.user?.login) { + console.log('[beginner-assign] Exit: missing commenter login'); + return; + } + + if (comment.user.type === 'Bot') { + console.log('[beginner-assign] Exit: comment authored by bot'); + return; + } + + // Gentle reminder if user comments without /assign + if (!commentRequestsAssignment(comment.body)) { + if ( + issueIsBeginnerIssue(issue) && + !issue.assignees?.length + ) { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: issue.number, + per_page: 100, + } + ); + + const reminderAlreadyPosted = comments.some(c => + c.body?.includes(ASSIGN_REMINDER_MARKER) + ); + + console.log('[beginner-assign] Reminder check:', { + reminderAlreadyPosted, + }); + + if (!reminderAlreadyPosted) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: buildAssignReminder(comment.user.login), + }); + + console.log('[beginner-assign] Posted /assign reminder'); + } + } + + console.log('[beginner-assign] Exit: comment does not request assignment'); + return; + } + + console.log('[beginner-assign] Assignment command detected'); + + // Only act on Beginner Issues + if (!issueIsBeginnerIssue(issue)) { + console.log('[beginner-assign] Exit: issue is not Beginner Issue'); + return; + } + + const requester = comment.user.login; + + console.log('[beginner-assign] Requester:', requester); + + // Already assigned + if (issue.assignees?.length > 0) { + console.log('[beginner-assign] Exit: issue already assigned'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: commentAlreadyAssigned(requester, issue), + }); + + console.log('[beginner-assign] Posted already-assigned comment'); + return; + } + + console.log('[beginner-assign] Checking GFI completion for user'); + + // Enforce GFI prerequisite + const hasGfi = await hasCompletedGfi({ + github, + owner, + repo, + username: requester, + }); + + console.log('[beginner-assign] hasCompletedGfi result:', { + requester, + hasGfi, + }); + + if (!hasGfi) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: buildMissingGfiRequirementComment(requester), + }); + + console.log('[beginner-assign] Posted missing-GFI requirement comment'); + return; + } + + console.log('[beginner-assign] Assigning issue to requester'); + + // Assign issue + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: [requester], + }); + + console.log('[beginner-assign] Assignment completed successfully'); + } catch (error) { + console.error('[beginner-assign] Error:', { + message: error.message, + status: error.status, + issueNumber: context.payload.issue?.number, + commenter: context.payload.comment?.user?.login, + }); + throw error; + } +}; diff --git a/.github/scripts/bot-intermediate-assignment.js b/.github/scripts/bot-intermediate-assignment.js deleted file mode 100644 index 876863fb2..000000000 --- a/.github/scripts/bot-intermediate-assignment.js +++ /dev/null @@ -1,230 +0,0 @@ -const COMMENT_MARKER = process.env.INTERMEDIATE_COMMENT_MARKER || ''; -const INTERMEDIATE_LABEL = process.env.INTERMEDIATE_LABEL?.trim() || 'intermediate'; -const GFI_LABEL = process.env.GFI_LABEL?.trim() || 'Good First Issue'; -const EXEMPT_PERMISSION_LEVELS = (process.env.INTERMEDIATE_EXEMPT_PERMISSIONS || 'admin,maintain,write,triage') - .split(',') - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); -const DRY_RUN = /^true$/i.test(process.env.DRY_RUN || ''); - -function hasLabel(issue, labelName) { - if (!issue?.labels?.length) { - return false; - } - - return issue.labels.some((label) => { - const name = typeof label === 'string' ? label : label?.name; - return typeof name === 'string' && name.toLowerCase() === labelName.toLowerCase(); - }); -} - -async function hasExemptPermission(github, owner, repo, username) { - if (!EXEMPT_PERMISSION_LEVELS.length) { - console.log(`No exempt permission levels configured. Skipping permission check for ${username} in ${owner}/${repo}.`); - return false; - } - - console.log(`Checking repository permissions for ${username} in ${owner}/${repo} against exempt levels: [${EXEMPT_PERMISSION_LEVELS.join(', ')}]`); - - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username, - }); - - const permission = response?.data?.permission?.toLowerCase(); - const isExempt = Boolean(permission) && EXEMPT_PERMISSION_LEVELS.includes(permission); - - console.log(`Permission check for ${username} in ${owner}/${repo}: permission='${permission}', exempt=${isExempt}`); - - return isExempt; - } catch (error) { - if (error?.status === 404) { - console.log(`User ${username} not found as collaborator in ${owner}/${repo} (404). Treating as non-exempt.`); - return false; - } - - const message = error instanceof Error ? error.message : String(error); - console.log(`Unable to verify ${username}'s repository permissions in ${owner}/${repo}: ${message}`); - return false; - } -} - -async function countCompletedGfiIssues(github, owner, repo, username) { - try { - console.log(`Checking closed '${GFI_LABEL}' issues in ${owner}/${repo} for ${username}.`); - const iterator = github.paginate.iterator(github.rest.issues.listForRepo, { - owner, - repo, - state: 'closed', - labels: GFI_LABEL, - assignee: username, - sort: 'updated', - direction: 'desc', - per_page: 100, - }); - - const normalizedAssignee = username.toLowerCase(); - let pageCount = 0; - const MAX_PAGES = 8; - - for await (const { data: issues } of iterator) { - pageCount += 1; - if (pageCount > MAX_PAGES) { - console.log(`Reached pagination safety cap (${MAX_PAGES}) while checking GFIs for ${username}.`); - break; - } - - console.log(`Scanning page ${pageCount} of closed '${GFI_LABEL}' issues for ${username} (items: ${issues.length}).`); - const match = issues.find((issue) => { - if (issue.pull_request) { - return false; - } - - const assignees = Array.isArray(issue.assignees) ? issue.assignees : []; - return assignees.some((assignee) => assignee?.login?.toLowerCase() === normalizedAssignee); - }); - - if (match) { - console.log(`Found matching GFI issue #${match.number} (${match.html_url || 'no url'}) for ${username}.`); - return 1; - } - } - - return 0; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Unable to verify completed GFIs for ${username}: ${message}`); - return null; - } -} - -async function hasExistingGuardComment(github, owner, repo, issueNumber, mentee) { - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: issueNumber, - per_page: 100, - }); - - return comments.some((comment) => { - if (!comment?.body?.includes(COMMENT_MARKER)) { - return false; - } - - const normalizedBody = comment.body.toLowerCase(); - const normalizedMentee = `@${mentee}`.toLowerCase(); - - return normalizedBody.includes(normalizedMentee); - }); -} - -function buildRejectionComment({ mentee, completedCount }) { - const plural = completedCount === 1 ? '' : 's'; - - return `${COMMENT_MARKER} -Hi @${mentee}! Thanks for your interest in contributing 💡 - -This issue is labeled as intermediate, which means it requires a bit more familiarity with the SDK. -Before you can take it on, please complete at least one Good First Issue so we can make sure you have a smooth on-ramp. - -You've completed **${completedCount}** Good First Issue${plural} so far. -Once you wrap up your first GFI, feel free to come back and we’ll gladly help you get rolling here!`; -} - -module.exports = async ({ github, context }) => { - try { - if (DRY_RUN) { - console.log('Running intermediate guard in dry-run mode: no assignee removals or comments will be posted.'); - } - - const issue = context.payload.issue; - const assignee = context.payload.assignee; - - if (!issue?.number || !assignee?.login) { - return console.log('Missing issue or assignee in payload. Skipping intermediate guard.'); - } - - const { owner, repo } = context.repo; - const mentee = assignee.login; - - console.log(`Processing intermediate guard for issue #${issue.number} in ${owner}/${repo}: assignee=${mentee}, dry_run=${DRY_RUN}`); - - if (!hasLabel(issue, INTERMEDIATE_LABEL)) { - return console.log(`Issue #${issue.number} is not labeled '${INTERMEDIATE_LABEL}'. Skipping.`); - } - - if (assignee.type === 'Bot') { - return console.log(`Assignee ${mentee} is a bot. Skipping.`); - } - - if (await hasExemptPermission(github, owner, repo, mentee)) { - console.log(`✅ ${mentee} has exempt repository permissions in ${owner}/${repo}. Skipping guard.`); - return; - } - - const completedCount = await countCompletedGfiIssues(github, owner, repo, mentee); - - if (completedCount === null) { - return console.log(`Skipping guard for @${mentee} on issue #${issue.number} due to API error when verifying GFIs.`); - } - - if (completedCount >= 1) { - console.log(`✅ ${mentee} has completed ${completedCount} GFI(s). Assignment allowed.`); - return; - } - - console.log(`❌ ${mentee} has completed ${completedCount} GFI(s). Assignment not allowed; proceeding with removal and comment.`); - - try { - if (DRY_RUN) { - console.log(`[dry-run] Would remove @${mentee} from issue #${issue.number} due to missing GFI completion.`); - } else { - await github.rest.issues.removeAssignees({ - owner, - repo, - issue_number: issue.number, - assignees: [mentee], - }); - console.log(`Removed @${mentee} from issue #${issue.number} due to missing GFI completion.`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Unable to remove assignee ${mentee} from issue #${issue.number}: ${message}`); - } - - try { - if (await hasExistingGuardComment(github, owner, repo, issue.number, mentee)) { - return console.log(`Guard comment already exists on issue #${issue.number}. Skipping duplicate message.`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Unable to check for existing guard comment: ${message}. Proceeding to post comment anyway (accepting small risk of duplicate).`); - } - - const comment = buildRejectionComment({ mentee, completedCount }); - - try { - if (DRY_RUN) { - console.log('[dry-run] Would post guard comment with body:\n', comment); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: comment, - }); - - console.log(`Posted guard comment for @${mentee} on issue #${issue.number}.`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Unable to post guard comment for @${mentee} on issue #${issue.number}: ${message}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`❌ Intermediate assignment guard failed: ${message}`); - throw error; - } -}; diff --git a/.github/scripts/bots/assign-01-gfi-auto.js b/.github/scripts/bots/assign-01-gfi-auto.js new file mode 100644 index 000000000..ef32730b6 --- /dev/null +++ b/.github/scripts/bots/assign-01-gfi-auto.js @@ -0,0 +1,197 @@ +/** + * Auto-assigns a Good First Issue when a user comments `/assign`. + * + * Architecture: + * - Policy lives in lib/eligibility/* + * - Messaging lives in lib/comments/* + * - This file only orchestrates + */ + +const { isTeam } = require('../lib/team/has-team'); +const { hasGfiEligibility } = require('../lib/eligibility/has-eligibility-01-gfi'); +const { rejectionRouter } = require('../lib/comments/rejection-router'); +const { assignReminder } = require('../lib/comments/reminder-to-request-assign'); +const { alreadyAssigned } = require('../lib/comments/issue-already-assigned'); + +const GOOD_FIRST_ISSUE_LABEL = 'Good First Issue'; +const ASSIGN_REMINDER_MARKER = ''; + +const BROWSE_URLS = { + gfi: 'https://github.com/hiero-ledger/hiero-sdk-python/issues?q=is%3Aissue+state%3Aopen+label%3A"Good+First+Issue"+no%3Aassignee', +}; + +function requestsAssignment(body) { + return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); +} + +function isGfiIssue(issue) { + return (issue.labels ?? []).some(l => l.name === GOOD_FIRST_ISSUE_LABEL); +} + +module.exports = async ({ github, context }) => { + const { issue, comment } = context.payload; + const { owner, repo } = context.repo; + + // ───────────────────────────────────────────── + // Guard rails & early exits + // ───────────────────────────────────────────── + if (!issue || !comment) { + console.log('[gfi-assign] Exit: missing issue or comment'); + return; + } + + if (comment.user?.type === 'Bot') { + console.log('[gfi-assign] Exit: comment authored by bot'); + return; + } + + if (!isGfiIssue(issue)) { + console.log('[gfi-assign] Exit: issue is not Good First Issue', { + labels: issue.labels?.map(l => l.name), + }); + return; + } + + const username = comment.user.login; + + console.log('[gfi-assign] Start', { + issue: issue.number, + username, + commentBody: comment.body, + }); + + // ───────────────────────────────────────────── + // Reminder flow (no /assign) + // ───────────────────────────────────────────── + if (!requestsAssignment(comment.body)) { + console.log('[gfi-assign] No /assign detected, evaluating reminder'); + + const isTeamMember = await isTeam({ github, owner, repo, username }); + + console.log('[gfi-assign] Reminder eligibility', { + isTeamMember, + hasAssignee: !!issue.assignees?.length, + }); + + if (!issue.assignees?.length && !isTeamMember) { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: issue.number, + per_page: 100, + } + ); + + const alreadyReminded = comments.some(c => + c.body?.includes(ASSIGN_REMINDER_MARKER) + ); + + console.log('[gfi-assign] Reminder presence', { + alreadyReminded, + }); + + if (!alreadyReminded) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: + ASSIGN_REMINDER_MARKER + + assignReminder(username, 'Good First'), + }); + + console.log('[gfi-assign] Posted assign reminder'); + } + } + + console.log('[gfi-assign] Exit: reminder path complete'); + return; + } + + console.log('[gfi-assign] /assign command detected'); + + // ───────────────────────────────────────────── + // Already assigned + // ───────────────────────────────────────────── + if (issue.assignees?.length) { + console.log('[gfi-assign] Exit: issue already assigned', { + assignee: issue.assignees[0]?.login, + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: alreadyAssigned({ + username, + assignee: `@${issue.assignees[0].login}`, + browseUrl: BROWSE_URLS.gfi, + tierLabel: 'Good First', + }), + }); + + return; + } + + // ───────────────────────────────────────────── + // Eligibility check (POLICY) + // ───────────────────────────────────────────── + const result = await hasGfiEligibility({ + github, + owner, + repo, + username, + }); + + console.log('[gfi-assign] Eligibility result', { + username, + result, + }); + + if (!result.eligible) { + const body = rejectionRouter({ + reason: result.reason, + context: result.context, + username, + urls: BROWSE_URLS, + }); + + console.log('[gfi-assign] Rejection routed', { + reason: result.reason, + hasBody: !!body, + }); + + if (body) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body, + }); + } + + return; + } + + // ───────────────────────────────────────────── + // Assign issue + // ───────────────────────────────────────────── + console.log('[gfi-assign] Assigning issue', { + issue: issue.number, + assignee: username, + }); + + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: [username], + }); + + console.log('[gfi-assign] Assigned successfully', { + issue: issue.number, + username, + }); +}; diff --git a/.github/scripts/bots/assign-02-beginner-auto.js b/.github/scripts/bots/assign-02-beginner-auto.js new file mode 100644 index 000000000..ab1a056d5 --- /dev/null +++ b/.github/scripts/bots/assign-02-beginner-auto.js @@ -0,0 +1,152 @@ +/** + * Auto-assigns a Beginner issue when a user comments `/assign`. + * + * Architecture: + * - Policy lives in lib/eligibility/* + * - Messaging lives in lib/comments/* + * - This file only orchestrates + */ + +const { isTeam } = + require('../lib/team/has-team'); + +const { hasBeginnerEligibility } = + require('../lib/eligibility/has-eligibility-02-beginner'); + +const { rejectionRouter } = + require('../lib/comments/rejection-router'); + +const { assignReminder } = + require('../lib/comments/reminder-to-request-assign'); + +const { alreadyAssigned } = + require('../lib/comments/issue-already-assigned'); + +const BEGINNER_LABEL = 'beginner'; +const ASSIGN_REMINDER_MARKER = ''; + +const BROWSE_URLS = { + gfi: 'https://github.com/hiero-ledger/hiero-sdk-python/issues?q=is%3Aissue+state%3Aopen+label%3A"Good+First+Issue"+no%3Aassignee', + beginner: 'https://github.com/hiero-ledger/hiero-sdk-python/issues?q=is%3Aissue+state%3Aopen+label%3Abeginner+no%3Aassignee', +}; + +function requestsAssignment(body) { + return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); +} + +function isBeginnerIssue(issue) { + return (issue.labels ?? []).some(l => l.name === BEGINNER_LABEL); +} + +module.exports = async ({ github, context }) => { + const { issue, comment } = context.payload; + const { owner, repo } = context.repo; + + if (!issue || !comment) return; + if (comment.user?.type === 'Bot') return; + if (!isBeginnerIssue(issue)) return; + + const username = comment.user.login; + + console.log('[beginner-assign] Start', { + issue: issue.number, + username, + }); + + // ───────────────────────────────────────────── + // Gentle reminder if user comments but not /assign + // ───────────────────────────────────────────── + if (!requestsAssignment(comment.body)) { + if ( + !issue.assignees?.length && + !(await isTeam({ github, owner, repo, username })) + ) { + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: issue.number, per_page: 100 } + ); + + const alreadyReminded = comments.some(c => + c.body?.includes(ASSIGN_REMINDER_MARKER) + ); + + if (!alreadyReminded) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: + ASSIGN_REMINDER_MARKER + + assignReminder(username, 'Beginner'), + }); + + console.log('[beginner-assign] Posted assign reminder'); + } + } + + return; + } + + // ───────────────────────────────────────────── + // Already assigned + // ───────────────────────────────────────────── + if (issue.assignees?.length) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: alreadyAssigned({ + username, + assignee: `@${issue.assignees[0].login}`, + browseUrl: BROWSE_URLS.beginner, + tierLabel: 'Beginner', + }), + }); + return; + } + + // ───────────────────────────────────────────── + // Eligibility check (POLICY) + // ───────────────────────────────────────────── + const result = await hasBeginnerEligibility({ + github, + owner, + repo, + username, + }); + + if (!result.eligible) { + const body = rejectionRouter({ + reason: result.reason, + context: result.context, + username, + urls: BROWSE_URLS, + }); + + if (body) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body, + }); + } + + return; + } + + // ───────────────────────────────────────────── + // Assign issue + // ───────────────────────────────────────────── + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: [username], + }); + + console.log('[beginner-assign] Assigned successfully', { + issue: issue.number, + username, + }); +}; diff --git a/.github/scripts/check_advanced_requirement.sh b/.github/scripts/check_advanced_requirement.sh deleted file mode 100644 index 0b982a52d..000000000 --- a/.github/scripts/check_advanced_requirement.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# 1. Define Helper Functions First -log() { - echo "[advanced-check] $1" -} - -# 2. Validate required environment variables -if [[ -z "${REPO:-}" ]] || [[ -z "${ISSUE_NUMBER:-}" ]] || [[ -z "${GH_TOKEN:-}" ]]; then - log "ERROR: Required environment variables (REPO, ISSUE_NUMBER, GH_TOKEN) must be set" - exit 1 -fi - -# 3. Function to check a single user -check_user() { - local user=$1 - log "Checking qualification for @$user..." - - # Permission exemption - PERM_JSON=$(gh api "repos/$REPO/collaborators/$user/permission" 2>/dev/null || echo '{"permission":"none"}') - PERMISSION=$(echo "$PERM_JSON" | jq -r '.permission // "none"') - - if [[ "$PERMISSION" =~ ^(admin|write|triage)$ ]]; then - log "User @$user is core member ($PERMISSION). Qualification check skipped." - return 0 - fi - - # 2. Get counts - # Using exact repository label names ("Good First Issue" and "intermediate") - GFI_QUERY="repo:$REPO is:issue is:closed assignee:$user -reason:\"not planned\" label:\"Good First Issue\"" - INT_QUERY="repo:$REPO is:issue is:closed assignee:$user -reason:\"not planned\" label:\"intermediate\"" - - GFI_COUNT=$(gh api "search/issues" -f q="$GFI_QUERY" --jq '.total_count' || echo "0") - INT_COUNT=$(gh api "search/issues" -f q="$INT_QUERY" --jq '.total_count' || echo "0") - - # Numeric validation - if ! [[ "$GFI_COUNT" =~ ^[0-9]+$ ]]; then GFI_COUNT=0; fi - if ! [[ "$INT_COUNT" =~ ^[0-9]+$ ]]; then INT_COUNT=0; fi - - # Validation Logic - if (( GFI_COUNT >= 1 )) && (( INT_COUNT >= 1 )); then - log "User @$user qualified." - return 0 - else - log "User @$user failed. Unassigning..." - - # Tailor the suggestion based on what is missing - # Links and names now match exact repository labels - if (( GFI_COUNT == 0 )); then - SUGGESTION="[Good First Issue](https://github.com/$REPO/labels/Good%20First%20Issue)" - else - SUGGESTION="[intermediate issue](https://github.com/$REPO/labels/intermediate)" - fi - - # Post the message FIRST, then unassign. - MSG="Hi @$user, I cannot assign you to this issue yet. - -**Why?** -Advanced issues involve high-risk changes to the core codebase. They require significant testing and can impact automation and CI behavior. - -**Requirement:** -- Complete at least **1** 'Good First Issue' (You have: **$GFI_COUNT**) -- Complete at least **1** 'intermediate' issue (You have: **$INT_COUNT**) - -Please check out our **$SUGGESTION** tasks to build your experience first!" - - gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$MSG" - gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-assignee "$user" - fi -} - -# --- Main Logic --- - -if [[ -n "${TRIGGER_ASSIGNEE:-}" ]]; then - check_user "$TRIGGER_ASSIGNEE" -else - log "Checking all current assignees..." - - # Fetch assignees into a variable first. - ASSIGNEE_LIST=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json assignees --jq '.assignees[].login') - - if [[ -z "$ASSIGNEE_LIST" ]]; then - log "No assignees found to check." - else - # Use a here-string (<<<) to iterate over the variable safely. - while read -r user; do - if [[ -n "$user" ]]; then - check_user "$user" - fi - done <<< "$ASSIGNEE_LIST" - fi -fi \ No newline at end of file diff --git a/.github/scripts/lib/comments/difficulty-02-beginner.js b/.github/scripts/lib/comments/difficulty-02-beginner.js new file mode 100644 index 000000000..41bf1791f --- /dev/null +++ b/.github/scripts/lib/comments/difficulty-02-beginner.js @@ -0,0 +1,42 @@ +/** + * Generates a rejection message when a contributor is not yet + * eligible to be assigned a beginner issue. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * + * @param {Object} params + * @param {string} params.username - GitHub username of the contributor + * @param {number} params.completedGfiCount - Number of completed Good First Issues + * @param {string} params.browseGfiUrl - URL to browse available Good First Issues + * @returns {string} Formatted markdown message explaining the restriction + */ +const beginnerRejection = ({ + username, + completedGfiCount, + browseGfiUrl, +}) => { + const gfiPlural = completedGfiCount === 1 ? '' : 's'; + + return `Hi @${username}, + +Thank you for your interest in contributing — we’re glad to see you here. + +This issue is labeled **Beginner**, which is intended as the next step after completing a **Good First Issue**. + +**Requirement:** +- Completion of **one Good First Issue** + +**Your progress:** +- Completed **${completedGfiCount}** Good First Issue${gfiPlural} + +You can find available tasks here: +**[Browse unassigned Good First Issues](${browseGfiUrl})** + +Once you’ve completed a GFI, feel free to come back and request this issue again.`; +}; + +module.exports = { + beginnerRejection, +}; diff --git a/.github/scripts/lib/comments/difficulty-03-intermediate.js b/.github/scripts/lib/comments/difficulty-03-intermediate.js new file mode 100644 index 000000000..fc72bafa3 --- /dev/null +++ b/.github/scripts/lib/comments/difficulty-03-intermediate.js @@ -0,0 +1,57 @@ +/** + * Generates a rejection message when a contributor does not yet + * meet the requirements to be assigned an intermediate issue. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {Object} params + * @param {string} params.username - GitHub username of the contributor + * @param {number} params.completedGfiCount - Number of completed Good First Issues + * @param {boolean} params.hasBeginner - Whether a Beginner issue has been completed + * @param {string} params.browseGfiUrl - URL to browse Good First Issues + * @param {string} params.browseBeginnerUrl - URL to browse Beginner issues + * @returns {string} Formatted markdown message explaining the restriction + */ +const INTERMEDIATE_GUARD_MARKER = ''; + +const intermediateRejection = ({ + username, + completedGfiCount, + hasBeginner, + browseGfiUrl, + browseBeginnerUrl, +}) => { + const gfiPlural = completedGfiCount === 1 ? '' : 's'; + const reasons = []; + + if (completedGfiCount === 0) { + reasons.push('• You have not completed a **Good First Issue** yet'); + } + + if (!hasBeginner) { + reasons.push('• You have not completed a **Beginner issue** yet'); + } + + return `${INTERMEDIATE_GUARD_MARKER} +Hi @${username}, thank you for your interest in this issue. + +This issue is labeled **Intermediate**, which means it requires some prior experience with the project. + +**Why you can’t be assigned right now:** +${reasons.join('\n')} + +**Your progress so far:** +- Completed **${completedGfiCount}** Good First Issue${gfiPlural} +- Beginner issue completed: **${hasBeginner ? 'Yes' : 'No'}** + +**Suggested next steps:** +- [Browse unassigned Good First Issues](${browseGfiUrl}) +- [Browse unassigned Beginner issues](${browseBeginnerUrl}) + +Once you meet the requirements, you’re welcome to come back and request this issue again.`; +}; + +module.exports = { + intermediateRejection, +}; diff --git a/.github/scripts/lib/comments/difficulty-04-advanced.js b/.github/scripts/lib/comments/difficulty-04-advanced.js new file mode 100644 index 000000000..4cb1041a2 --- /dev/null +++ b/.github/scripts/lib/comments/difficulty-04-advanced.js @@ -0,0 +1,33 @@ +/** + * Generates a rejection message when a contributor lacks the + * required experience to be assigned an advanced issue. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {Object} params + * @param {string} params.username - GitHub username of the contributor + * @param {number} params.intermediateCount - Number of completed intermediate issues + * @param {string} params.suggestionLabel - Label for suggested issues - pass advanced + * @param {string} params.suggestionUrl - URL to suggested issues - pass advanced URL + * @returns {string} Formatted markdown message explaining the restriction + */ +const advancedRejection = ({ + username, + intermediateCount, + suggestionLabel, + suggestionUrl, +}) => `Hi @${username}, I can’t assign you to this issue just yet. + +**Why?** +Advanced issues involve higher-risk changes to core parts of the codebase. They typically require more extensive testing and may impact automation and CI behavior. + +**Requirements:** +- Completion of at least **2 intermediate issues** + (you have completed **${intermediateCount}**) + +To build the required experience, please review our **[${suggestionLabel}](${suggestionUrl})** tasks. Once you’ve completed a few more, you’ll be eligible to work on advanced issues.`; + +module.exports = { + advancedRejection, +}; diff --git a/.github/scripts/lib/comments/issue-already-assigned.js b/.github/scripts/lib/comments/issue-already-assigned.js new file mode 100644 index 000000000..8d283db13 --- /dev/null +++ b/.github/scripts/lib/comments/issue-already-assigned.js @@ -0,0 +1,31 @@ +/** + * Generates a message when an issue cannot be assigned because it + * already has an assignee. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {Object} params + * @param {string} params.username - GitHub username of the requester + * @param {string} params.assignee - Current assignee of the issue + * @param {string} params.browseUrl - URL to browse unassigned issues + * @param {string} params.tierLabel - Issue tier label (e.g. Beginner, Intermediate) + * @returns {string} Formatted markdown message explaining the status + */ +const alreadyAssigned = ({ + username, + assignee, + browseUrl, + tierLabel, +}) => `Hi @${username}, thank you for your interest in this issue. + +This issue is currently assigned to **${assignee}**, so it can’t be assigned again at the moment. + +You can browse other unassigned **${tierLabel}** issues here: +**[View unassigned ${tierLabel} issues](${browseUrl})** + +If you find an issue you’d like to work on, feel free to comment \`/assign\` and we’ll take it from there.`; + +module.exports = { + alreadyAssigned, +}; diff --git a/.github/scripts/lib/comments/max-assignments-reached.js b/.github/scripts/lib/comments/max-assignments-reached.js new file mode 100644 index 000000000..a4ca3e807 --- /dev/null +++ b/.github/scripts/lib/comments/max-assignments-reached.js @@ -0,0 +1,28 @@ +/** + * Generates a message when a contributor has reached the maximum + * number of open issue assignments allowed. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {Object} params + * @param {string} params.username - GitHub username of the contributor + * @param {number} params.openAssignedCount - Number of currently assigned open issues + * @param {number} params.maxAllowed - Maximum number of allowed open issues + * @returns {string} Formatted markdown message explaining the limit + */ +const capacityLimitReached = ({ + username, + openAssignedCount, + maxAllowed, +}) => `Hi @${username}, thank you for your interest. + +You currently have **${openAssignedCount} open issue${openAssignedCount === 1 ? '' : 's'}** assigned. + +To keep assignments manageable, contributors may have at most **${maxAllowed} open issues** assigned at one time. + +Once you complete or unassign one of your current issues, you’re welcome to request another.`; + +module.exports = { + capacityLimitReached, +}; diff --git a/.github/scripts/lib/comments/rejection-router.js b/.github/scripts/lib/comments/rejection-router.js new file mode 100644 index 000000000..03cc42fa1 --- /dev/null +++ b/.github/scripts/lib/comments/rejection-router.js @@ -0,0 +1,32 @@ +const { beginnerRejection } = require('./difficulty-02-beginner'); +const { capacityLimitReached } = require('./max-assignments-reached'); +const { spamNonGfiAssignment } = require('./spam-restrictions'); + +const REJECTION_REASONS = + require('../eligibility/rejection-reasons'); + +const rejectionRouter = ({ reason, context, username, urls }) => { + switch (reason) { + case REJECTION_REASONS.MISSING_GFI: + return beginnerRejection({ + username, + completedGfiCount: context.completedGfiCount, + browseGfiUrl: urls.gfi, + }); + + case REJECTION_REASONS.CAPACITY: + return capacityLimitReached({ + username, + openAssignedCount: context.openAssignedCount, + maxAllowed: context.maxAllowed, + }); + + case REJECTION_REASONS.SPAM: + return spamNonGfiAssignment(username); + + default: + return null; + } +}; + +module.exports = { rejectionRouter }; diff --git a/.github/scripts/lib/comments/reminder-to-request-assign.js b/.github/scripts/lib/comments/reminder-to-request-assign.js new file mode 100644 index 000000000..6f5ca3d02 --- /dev/null +++ b/.github/scripts/lib/comments/reminder-to-request-assign.js @@ -0,0 +1,29 @@ +/** + * Generates a reminder message explaining how to request assignment + * to an issue. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {string} username - GitHub username of the contributor + * @param {string} tierName - Issue tier name (e.g. Beginner, Intermediate) + * @returns {string} Formatted markdown reminder message + */ +const BOT_SIGNATURE = '\n\n— Automated helper'; + +const assignReminder = (username, tierName) => ` +Hi @${username}, + +If you’d like to work on this **${tierName} issue**, please comment: + +\`\`\` +/assign +\`\`\` + +and the bot will handle the assignment process.${BOT_SIGNATURE} +`; + +module.exports = { + assignReminder, +}; + diff --git a/.github/scripts/lib/comments/spam-restrictions.js b/.github/scripts/lib/comments/spam-restrictions.js new file mode 100644 index 000000000..a582e536c --- /dev/null +++ b/.github/scripts/lib/comments/spam-restrictions.js @@ -0,0 +1,65 @@ +/** + * Generates a message when a contributor with restricted privileges + * attempts to be assigned to a non–Good First Issue. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {string} username - GitHub username of the contributor + * @returns {string} Formatted markdown message explaining the restriction + */ +const spamNonGfiAssignment = (username) => { + return `Hi @${username}, this is the Assignment Bot. + +:warning: **Assignment Restricted** + +Your account currently has limited assignment privileges. You may only be assigned to issues labeled **Good First Issue**. + +**Current Restrictions:** +- :white_check_mark: Can be assigned to 'Good First Issue' labeled issues (maximum 1 at a time) +- :x: Cannot be assigned to other issues + +**How to have restrictions lifted:** +1. Successfully complete and merge your assigned Good First Issue +2. Demonstrate consistent, quality contributions +3. Contact a maintainer to review your restriction status + +Thank you for your understanding!`; +}; + +/** + * Generates a message when a contributor with restricted privileges + * exceeds the maximum number of allowed open assignments. + * + * This helper is informational only and does not perform any + * assignment or eligibility logic. + * + * @param {string} username - GitHub username of the contributor + * @param {number} openCount - Number of currently open assignments + * @returns {string} Formatted markdown message explaining the limit + */ +const spamAssignmentLimitExceeded = (username, openCount) => { + return `Hi @${username}, this is the Assignment Bot. + +:warning: **Assignment Limit Exceeded** + +Your account currently has limited assignment privileges with a maximum of **1 open assignment** at a time. + +You currently have ${openCount} open issue(s) assigned. Please complete and merge your existing assignment before requesting a new one. + +**Current Restrictions:** +- Maximum 1 open assignment at a time +- Can only be assigned to 'Good First Issue' labeled issues + +**How to have restrictions lifted:** +1. Successfully complete and merge your current assigned issue +2. Demonstrate consistent, quality contributions +3. Contact a maintainer to review your restriction status + +Thank you for your cooperation!`; +}; + +module.exports = { + spamNonGfiAssignment, + spamAssignmentLimitExceeded, +}; diff --git a/.github/scripts/lib/counts/count-opened-assigned-issues.js b/.github/scripts/lib/counts/count-opened-assigned-issues.js new file mode 100644 index 000000000..a21294276 --- /dev/null +++ b/.github/scripts/lib/counts/count-opened-assigned-issues.js @@ -0,0 +1,72 @@ +/** + * Counts the number of open GitHub issues currently assigned to a user + * within a specific repository. + * + * FAILURE BEHAVIOR: + * - Fails open by returning a very large number if the GitHub API call + * fails, allowing callers to conservatively block new assignments. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check assignments for + * @returns {Promise} Number of open issues currently assigned + */ +const countOpenAssignedIssues = async ({ + github, + owner, + repo, + username, +}) => { + // Log the start of the check for traceability in Action logs + console.log('[count-open-assigned-issues] Start check:', { + owner, + repo, + username, + }); + + try { + // Use GitHub's search API to count open issues (not PRs) + // assigned to the given user within this repository. + // + // NOTE: + // - `is:issue` excludes pull requests + // - `is:open` ensures only active issues are counted + // - The search API returns a `total_count`, which avoids + // fetching and paginating individual issues. + const { data } = await github.rest.search.issuesAndPullRequests({ + q: [ + `repo:${owner}/${repo}`, + 'is:issue', + 'is:open', + `assignee:${username}`, + ].join(' '), + }); + + // Safely extract the count, defaulting to 0 if missing + const count = data?.total_count ?? 0; + + // Log the computed result for debugging and audits + console.log('[count-open-assigned-issues] Result:', { + username, + count, + }); + + return count; + } catch (error) { + // Log the error but do not throw + console.log('[count-open-assigned-issues] Error:', { + username, + message: error.message, + }); + + // Fail open by returning a very large number so callers + // treat this as "over the limit" and block new assignments. + return Number.MAX_SAFE_INTEGER; + } +}; + +module.exports = { + countOpenAssignedIssues, +}; diff --git a/.github/scripts/lib/counts/has-completed-n-01-gfi.js b/.github/scripts/lib/counts/has-completed-n-01-gfi.js new file mode 100644 index 000000000..04f573261 --- /dev/null +++ b/.github/scripts/lib/counts/has-completed-n-01-gfi.js @@ -0,0 +1,175 @@ +/** + * Determines whether a contributor has completed at least `requiredCount` + * Good First Issues (GFIs) in the given repository. + * + * A GFI is counted when a merged pull request authored by the contributor + * closes an issue labeled `Good First Issue`. + * + * IMPORTANT CONTEXT: + * - The `Good First Issue` label was introduced on 2025-07-14. + * - Pull requests merged before that date cannot qualify. + * - This helper stops scanning once PRs predate the label introduction + * to avoid unnecessary API calls. + * + * This helper is intentionally generic and parameterized. Policy decisions + * (for example, whether 1 or more GFIs are required) should be expressed + * at the call site. + * + * IMPLEMENTATION NOTES: + * - Searches merged PRs authored by the contributor (newest → oldest). + * - Stops scanning once PRs predate the GFI label introduction date. + * - Inspects PR timelines to identify issues closed by each PR. + * - Counts issues labeled `Good First Issue`. + * - Returns early once the required count is reached. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @param {number} params.requiredCount - Number of Good First Issues required + * @returns {Promise} Whether the contributor meets the requirement + */ +const GOOD_FIRST_ISSUE_LABEL = 'Good First Issue'; + +/** + * Date when the Good First Issue label began being used in this repository. + * Used as a hard cutoff to avoid scanning PRs that cannot qualify. + */ +const GFI_LABEL_INTRODUCED_AT = new Date('2025-07-14'); + +const hasCompletedGfi = async ({ + github, + owner, + repo, + username, + requiredCount, +}) => { + // Log the start of the eligibility check for traceability + console.log('[has-gfi] Start check:', { + owner, + repo, + username, + requiredCount, + }); + + // Fetch merged pull requests authored by the contributor. + // Results are ordered newest → oldest to allow early exits + // both on success and on label cutoff. + const prs = await github.paginate( + github.rest.search.issuesAndPullRequests, + { + q: `is:pr is:merged author:${username} repo:${owner}/${repo}`, + sort: 'updated', + order: 'desc', + per_page: 50, + } + ); + + // If the contributor has never merged a PR, they cannot + // have completed any Good First Issues. + if (!prs.length) { + console.log('[has-gfi] Exit: no merged PRs found'); + return false; + } + + let completedCount = 0; + + for (const pr of prs) { + // Prefer `closed_at` when available, as it most accurately + // represents when the PR was merged. + const mergedAt = new Date(pr.closed_at ?? pr.updated_at); + + // Stop scanning once PRs predate the GFI label introduction. + // Older PRs cannot possibly qualify. + if (mergedAt < GFI_LABEL_INTRODUCED_AT) { + console.log('[has-gfi] Stop: PR predates GFI label', { + prNumber: pr.number, + mergedAt: mergedAt.toISOString(), + }); + break; + } + + console.log('[has-gfi] Inspecting PR:', { + prNumber: pr.number, + prTitle: pr.title, + }); + + // Inspect the PR timeline to find issues that were closed + // as a result of this PR being merged. + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: pr.number, + per_page: 100, + } + ); + + for (const event of timeline) { + // We only care about "closed" events that reference an issue. + if ( + event.event === 'closed' && + event?.source?.issue?.number + ) { + const issueNumber = event.source.issue.number; + + // Fetch the linked issue so we can inspect its labels. + const { data: issue } = + await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const labels = + issue.labels?.map(label => label.name) ?? []; + + if (labels.includes(GOOD_FIRST_ISSUE_LABEL)) { + completedCount += 1; + + console.log( + '[has-gfi] Found completed Good First Issue', + { + username, + prNumber: pr.number, + issueNumber, + completedCount, + } + ); + + // Early exit once the required number of + // Good First Issues has been reached. + if (completedCount >= requiredCount) { + console.log( + '[has-gfi] Success: GFI requirement satisfied', + { + username, + completedCount, + requiredCount, + } + ); + + return true; + } + } + } + } + } + + // Contributor has merged PRs, but not enough that close + // Good First Issue–labeled issues to meet the requirement. + console.log('[has-gfi] Exit: insufficient completed Good First Issues', { + username, + completedCount, + requiredCount, + }); + + return false; +}; + +module.exports = { + GOOD_FIRST_ISSUE_LABEL, + hasCompletedGfi, +}; diff --git a/.github/scripts/lib/counts/has-completed-n-02-beginner.js b/.github/scripts/lib/counts/has-completed-n-02-beginner.js new file mode 100644 index 000000000..e64af9667 --- /dev/null +++ b/.github/scripts/lib/counts/has-completed-n-02-beginner.js @@ -0,0 +1,174 @@ +/** + * Determines whether a contributor has completed at least `requiredCount` + * Beginner issues in the given repository. + * + * A Beginner issue is counted when a merged pull request authored by + * the contributor closes an issue labeled `beginner`. + * + * IMPORTANT CONTEXT: + * - The `beginner` label was introduced on 2026-01-01. + * - Pull requests merged before that date cannot qualify. + * - This helper stops scanning once PRs predate the label introduction + * to avoid unnecessary API calls. + * + * This helper is intentionally generic and parameterized. Policy decisions + * (for example, whether 1 or more Beginner issues are required) should be + * expressed at the call site. + * + * IMPLEMENTATION NOTES: + * - Searches merged PRs authored by the contributor (newest → oldest). + * - Stops scanning once PRs predate the Beginner label introduction date. + * - Inspects PR timelines to identify issues closed by each PR. + * - Counts issues labeled `beginner`. + * - Returns early once the required count is reached. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @param {number} params.requiredCount - Number of Beginner issues required + * @returns {Promise} Whether the contributor meets the requirement + */ +const BEGINNER_ISSUE_LABEL = 'beginner'; + +/** + * Date when the Beginner label began being used in this repository. + * Used as a hard cutoff to avoid scanning PRs that cannot qualify. + */ +const BEGINNER_LABEL_INTRODUCED_AT = new Date('2026-01-01'); + +const hasCompletedBeginner = async ({ + github, + owner, + repo, + username, + requiredCount, +}) => { + // Log the start of the eligibility check for traceability + console.log('[has-beginner] Start check:', { + owner, + repo, + username, + requiredCount, + }); + + // Fetch merged pull requests authored by the contributor. + // Results are ordered newest → oldest to allow early exits + // both on success and on label cutoff. + const prs = await github.paginate( + github.rest.search.issuesAndPullRequests, + { + q: `is:pr is:merged author:${username} repo:${owner}/${repo}`, + sort: 'updated', + order: 'desc', + per_page: 50, + } + ); + + // If the contributor has never merged a PR, they cannot + // have completed any Beginner issues. + if (!prs.length) { + console.log('[has-beginner] Exit: no merged PRs found'); + return false; + } + + let completedCount = 0; + + for (const pr of prs) { + // Prefer `closed_at` when available, as it best represents + // when the PR was actually merged. + const mergedAt = new Date(pr.closed_at ?? pr.updated_at); + + // Stop scanning once PRs predate the Beginner label introduction. + // Older PRs cannot possibly qualify. + if (mergedAt < BEGINNER_LABEL_INTRODUCED_AT) { + console.log('[has-beginner] Stop: PR predates Beginner label', { + prNumber: pr.number, + mergedAt: mergedAt.toISOString(), + }); + break; + } + + console.log('[has-beginner] Inspecting PR:', { + prNumber: pr.number, + prTitle: pr.title, + }); + + // Inspect PR timeline events to find issues closed by this PR. + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: pr.number, + per_page: 100, + } + ); + + for (const event of timeline) { + // We only care about "closed" events that reference an issue. + if ( + event.event === 'closed' && + event?.source?.issue?.number + ) { + const issueNumber = event.source.issue.number; + + // Fetch the linked issue so we can inspect its labels. + const { data: issue } = + await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const labels = + issue.labels?.map(label => label.name) ?? []; + + if (labels.includes(BEGINNER_ISSUE_LABEL)) { + completedCount += 1; + + console.log( + '[has-beginner] Found completed Beginner issue', + { + username, + prNumber: pr.number, + issueNumber, + completedCount, + } + ); + + // Early exit once the required number of + // Beginner issues has been reached. + if (completedCount >= requiredCount) { + console.log( + '[has-beginner] Success: Beginner requirement satisfied', + { + username, + completedCount, + requiredCount, + } + ); + + return true; + } + } + } + } + } + + // Contributor has merged PRs, but not enough that close + // Beginner-labeled issues to meet the requirement. + console.log('[has-beginner] Exit: insufficient completed Beginner issues', { + username, + completedCount, + requiredCount, + }); + + return false; +}; + +module.exports = { + BEGINNER_ISSUE_LABEL, + hasCompletedBeginner, +}; diff --git a/.github/scripts/lib/counts/has-completed-n-03-intermediate.js b/.github/scripts/lib/counts/has-completed-n-03-intermediate.js new file mode 100644 index 000000000..2f64e9394 --- /dev/null +++ b/.github/scripts/lib/counts/has-completed-n-03-intermediate.js @@ -0,0 +1,158 @@ +/** + * Determines whether a contributor has completed at least `requiredCount` + * Intermediate issues in the given repository. + * + * An Intermediate issue is counted when a merged pull request authored + * by the contributor closes an issue labeled `intermediate`. + * + * IMPLEMENTATION NOTES: + * - Searches merged PRs authored by the contributor (newest → oldest). + * - Stops scanning once PRs predate the Intermediate label introduction date. + * - Inspects PR timelines to identify issues closed by each PR. + * - Counts issues labeled `intermediate`. + * - Returns early once the required count is reached. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @param {number} params.requiredCount - Number of Intermediate issues required + * @returns {Promise} Whether the contributor meets the requirement + */ +const INTERMEDIATE_ISSUE_LABEL = 'intermediate'; + +/** + * Date when the Intermediate label began being used in this repository. + * Used as a hard cutoff to avoid scanning PRs that cannot qualify. +*/ +const INTERMEDIATE_LABEL_INTRODUCED_AT = new Date('2025-12-05'); + +const hasCompletedIntermediate = async ({ + github, + owner, + repo, + username, + requiredCount, +}) => { + // Log the start of the eligibility check for traceability + console.log('[has-intermediate] Start check:', { + owner, + repo, + username, + requiredCount, + }); + + // Fetch merged pull requests authored by the contributor. + // Results are ordered newest → oldest to allow early exits + // both on success and on label cutoff. + const prs = await github.paginate( + github.rest.search.issuesAndPullRequests, + { + q: `is:pr is:merged author:${username} repo:${owner}/${repo}`, + sort: 'updated', + order: 'desc', + per_page: 50, + } + ); + + // If the contributor has never merged a PR, they cannot + // have completed any Intermediate issues. + if (!prs.length) { + console.log('[has-intermediate] Exit: no merged PRs found'); + return false; + } + + let completedCount = 0; + + for (const pr of prs) { + // Prefer `closed_at` when available, as it best represents + // when the PR was actually merged. + const mergedAt = new Date(pr.closed_at ?? pr.updated_at); + + // Stop scanning once PRs predate the Intermediate label introduction. + if (mergedAt < INTERMEDIATE_LABEL_INTRODUCED_AT) { + console.log('[has-intermediate] Stop: PR predates Intermediate label', { + prNumber: pr.number, + mergedAt: mergedAt.toISOString(), + }); + break; + } + + // Inspect the PR timeline to identify issues closed by this PR + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: pr.number, + per_page: 100, + } + ); + + for (const event of timeline) { + // We only care about "closed" events that reference an issue + if ( + event.event === 'closed' && + event?.source?.issue?.number + ) { + const issueNumber = event.source.issue.number; + + // Fetch the linked issue so we can inspect its labels + const { data: issue } = + await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const labels = + issue.labels?.map(label => label.name) ?? []; + + if (labels.includes(INTERMEDIATE_ISSUE_LABEL)) { + completedCount += 1; + + console.log( + '[has-intermediate] Found completed Intermediate issue', + { + username, + prNumber: pr.number, + issueNumber, + completedCount, + } + ); + + // Early exit once the required number of + // Intermediate issues has been reached. + if (completedCount >= requiredCount) { + console.log( + '[has-intermediate] Success: Intermediate requirement satisfied', + { + username, + completedCount, + requiredCount, + } + ); + + return true; + } + } + } + } + } + + // Contributor has merged PRs, but not enough that close + // Intermediate-labeled issues to meet the requirement. + console.log('[has-intermediate] Exit: insufficient completed Intermediate issues', { + username, + completedCount, + requiredCount, + }); + + return false; +}; + +module.exports = { + INTERMEDIATE_ISSUE_LABEL, + hasCompletedIntermediate, +}; diff --git a/.github/scripts/lib/counts/has-completed-n-04-advanced.js b/.github/scripts/lib/counts/has-completed-n-04-advanced.js new file mode 100644 index 000000000..5e68bf2ad --- /dev/null +++ b/.github/scripts/lib/counts/has-completed-n-04-advanced.js @@ -0,0 +1,157 @@ +/** + * Determines whether a contributor has completed at least `requiredCount` + * Advanced issues in the given repository. + * + * + * IMPLEMENTATION NOTES: + * - Searches merged PRs authored by the contributor (newest → oldest). + * - Stops scanning once PRs predate the Advanced label introduction date. + * - Inspects PR timelines to identify issues closed by each PR. + * - Counts issues labeled `advanced`. + * - Returns early once the required count is reached. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @param {number} params.requiredCount - Number of Advanced issues required + * @returns {Promise} Whether the contributor meets the requirement + */ +const ADVANCED_ISSUE_LABEL = 'advanced'; + +/** + * Date when the Advanced label began being used in this repository. + * Used as a hard cutoff to avoid scanning PRs that cannot qualify. + * + */ +const ADVANCED_LABEL_INTRODUCED_AT = new Date('2025-12-05'); + +const hasCompletedAdvanced = async ({ + github, + owner, + repo, + username, + requiredCount, +}) => { + // Log the start of the eligibility check for traceability + console.log('[has-advanced] Start check:', { + owner, + repo, + username, + requiredCount, + }); + + // Fetch merged pull requests authored by the contributor. + // Results are ordered newest → oldest to allow early exits + // both on success and on label cutoff. + const prs = await github.paginate( + github.rest.search.issuesAndPullRequests, + { + q: `is:pr is:merged author:${username} repo:${owner}/${repo}`, + sort: 'updated', + order: 'desc', + per_page: 50, + } + ); + + // If the contributor has never merged a PR, they cannot + // have completed any Advanced issues. + if (!prs.length) { + console.log('[has-advanced] Exit: no merged PRs found'); + return false; + } + + let completedCount = 0; + + for (const pr of prs) { + // Prefer `closed_at` when available, as it best represents + // when the PR was actually merged. + const mergedAt = new Date(pr.closed_at ?? pr.updated_at); + + // Stop scanning once PRs predate the Advanced label introduction. + if (mergedAt < ADVANCED_LABEL_INTRODUCED_AT) { + console.log('[has-advanced] Stop: PR predates Advanced label', { + prNumber: pr.number, + mergedAt: mergedAt.toISOString(), + }); + break; + } + + // Inspect the PR timeline to identify issues closed by this PR + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: pr.number, + per_page: 100, + } + ); + + for (const event of timeline) { + // We only care about "closed" events that reference an issue + if ( + event.event === 'closed' && + event?.source?.issue?.number + ) { + const issueNumber = event.source.issue.number; + + // Fetch the linked issue so we can inspect its labels + const { data: issue } = + await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const labels = + issue.labels?.map(label => label.name) ?? []; + + if (labels.includes(ADVANCED_ISSUE_LABEL)) { + completedCount += 1; + + console.log( + '[has-advanced] Found completed Advanced issue', + { + username, + prNumber: pr.number, + issueNumber, + completedCount, + } + ); + + // Early exit once the required number of + // Advanced issues has been reached. + if (completedCount >= requiredCount) { + console.log( + '[has-advanced] Success: Advanced requirement satisfied', + { + username, + completedCount, + requiredCount, + } + ); + + return true; + } + } + } + } + } + + // Contributor has merged PRs, but not enough that close + // Advanced-labeled issues to meet the requirement. + console.log('[has-advanced] Exit: insufficient completed Advanced issues', { + username, + completedCount, + requiredCount, + }); + + return false; +}; + +module.exports = { + ADVANCED_ISSUE_LABEL, + hasCompletedAdvanced, +}; diff --git a/.github/scripts/lib/counts/is-on-spam-list.js b/.github/scripts/lib/counts/is-on-spam-list.js new file mode 100644 index 000000000..5432c1ae0 --- /dev/null +++ b/.github/scripts/lib/counts/is-on-spam-list.js @@ -0,0 +1,132 @@ +/** + * Path to the repository-scoped spam list file. + * + * The spam list is stored as a plain text file with: + * - One GitHub username per line + * - Lines starting with `#` treated as comments + * - Usernames treated case-insensitively + */ +const SPAM_LIST_PATH = '.github/spam-list.txt'; + +/** + * In-memory cache of spam list entries. + * + * This cache is intentionally scoped to the lifetime of a single + * GitHub Action run to avoid repeated API calls and rate limiting. + * + * @type {Set | null} + */ +let cachedSpamSet = null; + +/** + * Loads and parses the repository spam list. + * + * The spam list is fetched from {@link SPAM_LIST_PATH}, normalized to + * lowercase, and cached in memory for the duration of the Action run. + * + * FAILURE BEHAVIOR: + * - If the spam list file does not exist, an empty set is returned. + * - If the spam list cannot be read for any reason, an empty set is + * returned (fail open) to avoid blocking contributors. + * + * This helper is intentionally read-only and side-effect free + * aside from populating the in-memory cache. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @returns {Promise>} Set of spam-listed usernames (lowercase) + */ +const loadSpamList = async ({ github, owner, repo }) => { + // Return the cached spam list if it has already been loaded + if (cachedSpamSet) { + return cachedSpamSet; + } + + try { + // Fetch the spam list file from the repository + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: SPAM_LIST_PATH, + }); + + // Decode file contents (GitHub API returns base64 by default) + const content = Buffer.from( + data.content, + data.encoding || 'base64' + ).toString('utf8'); + + // Parse the file into normalized username entries + const entries = content + .split('\n') + .map(line => line.trim()) + // Ignore empty lines + .filter(line => line.length > 0) + // Ignore commented lines + .filter(line => !line.startsWith('#')) + // Normalize usernames for case-insensitive matching + .map(line => line.toLowerCase()); + + cachedSpamSet = new Set(entries); + + console.log('[spam-list] Loaded spam list', { + count: cachedSpamSet.size, + }); + + return cachedSpamSet; + } catch (error) { + // If the spam list file does not exist, treat it as empty + if (error?.status === 404) { + console.log('[spam-list] No spam list found at', SPAM_LIST_PATH); + cachedSpamSet = new Set(); + return cachedSpamSet; + } + + // Any other error should fail open to avoid blocking contributors + console.log('[spam-list] Failed to load spam list', { + message: error.message, + }); + + cachedSpamSet = new Set(); + return cachedSpamSet; + } +}; + +/** + * Checks whether a contributor is listed in the repository spam list. + * + * This helper relies on the cached spam list loaded via {@link loadSpamList} + * and performs a case-insensitive membership check. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @returns {Promise} Whether the user is listed as spam + */ +const isOnSpamList = async ({ + github, + owner, + repo, + username, +}) => { + // Ensure the spam list is loaded and cached + const spamSet = await loadSpamList({ github, owner, repo }); + + // Normalize username before lookup + const isSpam = spamSet.has(username.toLowerCase()); + + console.log('[spam-list] Check:', { + username, + isSpam, + }); + + return isSpam; +}; + +module.exports = { + isOnSpamList, +}; diff --git a/.github/scripts/lib/eligibility/has-eligibility-01-gfi.js b/.github/scripts/lib/eligibility/has-eligibility-01-gfi.js new file mode 100644 index 000000000..0c228b774 --- /dev/null +++ b/.github/scripts/lib/eligibility/has-eligibility-01-gfi.js @@ -0,0 +1,43 @@ +const { isTeam } = require('../team/has-team'); +const { isOnSpamList } = require('../counts/is-on-spam-list'); +const { countOpenAssignedIssues } = require('../counts/count-open-assigned-issues'); +const REJECTION_REASONS = require('./rejection-reasons'); + +const MAX_OPEN_ISSUES_NORMAL = 2; +const MAX_OPEN_ISSUES_SPAM_LIST = 1; + +const hasGfiEligibility = async ({ + github, + owner, + repo, + username, +}) => { + if (await isTeam({ github, owner, repo, username })) { + return { eligible: true }; + } + + const isSpamListed = await isOnSpamList({ github, owner, repo, username }); + + const maxAllowed = isSpamListed + ? MAX_OPEN_ISSUES_SPAM_LIST + : MAX_OPEN_ISSUES_NORMAL; + + const openAssignedCount = await countOpenAssignedIssues({ + github, + owner, + repo, + username, + }); + + if (openAssignedCount >= maxAllowed) { + return { + eligible: false, + reason: REJECTION_REASONS.CAPACITY, + context: { openAssignedCount, maxAllowed }, + }; + } + + return { eligible: true }; +}; + +module.exports = { hasGfiEligibility }; diff --git a/.github/scripts/lib/eligibility/has-eligibility-02-beginner.js b/.github/scripts/lib/eligibility/has-eligibility-02-beginner.js new file mode 100644 index 000000000..755762636 --- /dev/null +++ b/.github/scripts/lib/eligibility/has-eligibility-02-beginner.js @@ -0,0 +1,77 @@ +const { isTeam } = require('../team/has-team'); +const { isOnSpamList } = require('../counts/is-on-spam-list'); +const { hasCompletedGfi } = require('../counts/has-completed-n-01-gfi'); +const { countOpenAssignedIssues } = require('../counts/count-open-assigned-issues'); +const REJECTION_REASONS = require('./rejection-reasons'); + +const MAX_OPEN_ASSIGNED_ISSUES = 2; +const REQUIRED_GFI_COUNT = 1; + +const hasBeginnerEligibility = async ({ + github, + owner, + repo, + username, +}) => { + console.log('[has-beginner-eligibility] Start', { + owner, + repo, + username, + }); + + // Team members bypass everything + if (await isTeam({ github, owner, repo, username })) { + return { eligible: true }; + } + + // Spam-listed users are never eligible + if (await isOnSpamList({ github, owner, repo, username })) { + return { + eligible: false, + reason: REJECTION_REASONS.SPAM, + }; + } + + // Capacity check + const openAssignedCount = await countOpenAssignedIssues({ + github, + owner, + repo, + username, + }); + + if (openAssignedCount >= MAX_OPEN_ASSIGNED_ISSUES) { + return { + eligible: false, + reason: REJECTION_REASONS.CAPACITY, + context: { + openAssignedCount, + maxAllowed: MAX_OPEN_ASSIGNED_ISSUES, + }, + }; + } + + // GFI prerequisite + const hasRequiredGfi = await hasCompletedGfi({ + github, + owner, + repo, + username, + requiredCount: REQUIRED_GFI_COUNT, + }); + + if (!hasRequiredGfi) { + return { + eligible: false, + reason: REJECTION_REASONS.MISSING_GFI, + context: { + completedGfiCount: 0, // safe fallback + }, + }; + } + + return { eligible: true }; +}; + +module.exports = { hasBeginnerEligibility }; + diff --git a/.github/scripts/lib/eligibility/has-eligibility-03-intermediate.js b/.github/scripts/lib/eligibility/has-eligibility-03-intermediate.js new file mode 100644 index 000000000..f15fb4f1b --- /dev/null +++ b/.github/scripts/lib/eligibility/has-eligibility-03-intermediate.js @@ -0,0 +1,99 @@ +const { isTeam } = require('../team/has-team'); +const { isOnSpamList } = require('../counts/is-on-spam-list'); +const { hasCompletedGfi } = require('../counts/has-completed-n-01-gfi'); +const { hasCompletedBeginner } = require('../counts/has-completed-n-02-beginner'); +const { countOpenAssignedIssues } = require('../counts/count-open-assigned-issues'); +const REJECTION_REASONS = require('./rejection-reasons'); + +const MAX_OPEN_ASSIGNED_ISSUES = 2; +const REQUIRED_GFI_COUNT = 1; +const REQUIRED_BEGINNER_COUNT = 1; + +const hasIntermediateEligibility = async ({ + github, + owner, + repo, + username, +}) => { + console.log('[has-intermediate-eligibility] Start', { + owner, + repo, + username, + }); + + // Team members bypass everything + if (await isTeam({ github, owner, repo, username })) { + return { eligible: true }; + } + + // Spam-listed users are never eligible + if (await isOnSpamList({ github, owner, repo, username })) { + return { + eligible: false, + reason: REJECTION_REASONS.SPAM, + }; + } + + // Capacity check + const openAssignedCount = await countOpenAssignedIssues({ + github, + owner, + repo, + username, + }); + + if (openAssignedCount >= MAX_OPEN_ASSIGNED_ISSUES) { + return { + eligible: false, + reason: REJECTION_REASONS.CAPACITY, + context: { + openAssignedCount, + maxAllowed: MAX_OPEN_ASSIGNED_ISSUES, + }, + }; + } + + // GFI requirement + const hasRequiredGfi = await hasCompletedGfi({ + github, + owner, + repo, + username, + requiredCount: REQUIRED_GFI_COUNT, + }); + + if (!hasRequiredGfi) { + return { + eligible: false, + reason: REJECTION_REASONS.MISSING_GFI, + context: { + requiredCount: REQUIRED_GFI_COUNT, + }, + }; + } + + // Beginner requirement + const hasRequiredBeginner = await hasCompletedBeginner({ + github, + owner, + repo, + username, + requiredCount: REQUIRED_BEGINNER_COUNT, + }); + + if (!hasRequiredBeginner) { + return { + eligible: false, + reason: REJECTION_REASONS.MISSING_BEGINNER, + context: { + requiredCount: REQUIRED_BEGINNER_COUNT, + }, + }; + } + + return { eligible: true }; +}; + +module.exports = { + hasIntermediateEligibility, +}; diff --git a/.github/scripts/lib/eligibility/has-eligibility-04-advanced.js b/.github/scripts/lib/eligibility/has-eligibility-04-advanced.js new file mode 100644 index 000000000..239846032 --- /dev/null +++ b/.github/scripts/lib/eligibility/has-eligibility-04-advanced.js @@ -0,0 +1,84 @@ +const { isCommitter } = + require('../team/has-team-committer-maintainer'); +const { isOnSpamList } = + require('../counts/is-on-spam-list'); +const { hasCompletedIntermediate } = + require('../counts/has-completed-n-03-intermediate'); +const { countOpenAssignedIssues } = + require('../counts/count-open-assigned-issues'); +const REJECTION_REASONS = + require('./rejection-reasons'); + +const MAX_OPEN_ASSIGNED_ISSUES = 2; +const REQUIRED_INTERMEDIATE_COUNT = 2; + +const hasAdvancedEligibility = async ({ + github, + owner, + repo, + username, +}) => { + console.log('[has-advanced-eligibility] Start', { + owner, + repo, + username, + }); + + // Committers bypass all checks + if (await isCommitter({ github, owner, repo, username })) { + return { eligible: true }; + } + + // Spam-listed users are never eligible + if (await isOnSpamList({ github, owner, repo, username })) { + return { + eligible: false, + reason: REJECTION_REASONS.SPAM, + }; + } + + // Capacity check + const openAssignedCount = await countOpenAssignedIssues({ + github, + owner, + repo, + username, + }); + + if (openAssignedCount >= MAX_OPEN_ASSIGNED_ISSUES) { + return { + eligible: false, + reason: REJECTION_REASONS.CAPACITY, + context: { + openAssignedCount, + maxAllowed: MAX_OPEN_ASSIGNED_ISSUES, + }, + }; + } + + // Intermediate completion requirement + const hasRequiredIntermediate = + await hasCompletedIntermediate({ + github, + owner, + repo, + username, + requiredCount: REQUIRED_INTERMEDIATE_COUNT, + }); + + if (!hasRequiredIntermediate) { + return { + eligible: false, + reason: REJECTION_REASONS.MISSING_INTERMEDIATE, + context: { + requiredCount: REQUIRED_INTERMEDIATE_COUNT, + }, + }; + } + + return { eligible: true }; +}; + +module.exports = { + hasAdvancedEligibility, +}; diff --git a/.github/scripts/lib/eligibility/rejection-reasons.js b/.github/scripts/lib/eligibility/rejection-reasons.js new file mode 100644 index 000000000..f91da8fb5 --- /dev/null +++ b/.github/scripts/lib/eligibility/rejection-reasons.js @@ -0,0 +1,7 @@ +module.exports = { + MISSING_GFI: 'missing_gfi', + MISSING_BEGINNER: 'missing_beginner', + MISSING_INTERMEDIATE: 'missing_intermediate', + CAPACITY: 'capacity_exceeded', + SPAM: 'spam_listed', +}; diff --git a/.github/scripts/lib/team/has-team-committer-maintainer.js b/.github/scripts/lib/team/has-team-committer-maintainer.js new file mode 100644 index 000000000..24c37c8d5 --- /dev/null +++ b/.github/scripts/lib/team/has-team-committer-maintainer.js @@ -0,0 +1,74 @@ +/** + * Determines whether a contributor has committer-level access + * to the repository. + * + * Committers are trusted contributors with elevated permissions + * (write / maintain / admin) and bypass all tier eligibility checks. + * + * FAILURE BEHAVIOR: + * - If the permission lookup fails for any reason other than + * "not a collaborator", this helper fails closed and returns `false`. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @returns {Promise} Whether the contributor is a committer + */ +const COMMITTER_PERMISSION_LEVELS = ['write', 'maintain', 'admin']; + +const isCommitter = async ({ + github, + owner, + repo, + username, +}) => { + try { + // Fetch the collaborator permission level for the user. + // This endpoint returns the user's highest permission + // within the repository. + const { data } = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + // Normalize permission for safe comparison. + const permission = + data?.permission?.toLowerCase() ?? 'none'; + + const isCommitter = + COMMITTER_PERMISSION_LEVELS.includes(permission); + + console.log('[is-committer] Permission check:', { + username, + permission, + isCommitter, + }); + + return isCommitter; + } catch (error) { + // 404 indicates the user is not a collaborator on the repo. + if (error?.status === 404) { + console.log('[is-committer] User is not a collaborator', { + username, + }); + return false; + } + + // Any other error (API failure, auth issues, etc.) + // fails closed to avoid granting unintended privileges. + console.log('[is-committer] Permission lookup failed', { + username, + message: error.message, + }); + + return false; + } +}; + +module.exports = { + isCommitter, +}; diff --git a/.github/scripts/lib/team/has-team-triage.js b/.github/scripts/lib/team/has-team-triage.js new file mode 100644 index 000000000..2a5f2fc64 --- /dev/null +++ b/.github/scripts/lib/team/has-team-triage.js @@ -0,0 +1,73 @@ +/** + * Determines whether a contributor is a member of the repository’s + * triage team **only**. + * + * This helper returns true **only if** the contributor’s highest + * permission level is exactly `triage`. + * + * IMPORTANT: + * - Committers (write / maintain / admin) are intentionally excluded. + * + * FAILURE BEHAVIOR: + * - If the permission lookup fails for any reason other than + * "not a collaborator", this helper fails closed and returns `false`. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @returns {Promise} Whether the contributor is a triage-only member + */ +const TRIAGE_PERMISSION_LEVEL = 'triage'; + +const isTriager = async ({ + github, + owner, + repo, + username, +}) => { + try { + // Fetch the collaborator permission level for the user, expect some to have triage. + // This returns the user's highest effective permission. + const { data } = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + const permission = + data?.permission?.toLowerCase() ?? 'none'; + + const isTriager = permission === TRIAGE_PERMISSION_LEVEL; + + console.log('[is-triager] Permission check:', { + username, + permission, + isTriager, + }); + + return isTriager; + } catch (error) { + // 404 indicates the user is not a collaborator. + if (error?.status === 404) { + console.log('[is-triager] User is not a collaborator', { + username, + }); + return false; + } + + // Fail closed to avoid granting unintended privileges. + console.log('[is-triager] Permission lookup failed', { + username, + message: error.message, + }); + + return false; + } +}; + +module.exports = { + isTriager, +}; diff --git a/.github/scripts/lib/team/has-team.js b/.github/scripts/lib/team/has-team.js new file mode 100644 index 000000000..1d91187f8 --- /dev/null +++ b/.github/scripts/lib/team/has-team.js @@ -0,0 +1,84 @@ +/** + * Determines whether a contributor is part of the repository team + * with triage-level or higher permissions. + * + * This includes users with: + * - triage + * - write + * - maintain + * - admin + * + * This helper is useful for: + * - Labeling + * - Moderation + * - Non-privileged workflow actions + * + * IMPORTANT: + * - This does NOT imply exemption from eligibility or capacity checks. + * + * FAILURE BEHAVIOR: + * - If the permission lookup fails for any reason other than + * "not a collaborator", this helper fails closed and returns `false`. + * + * @param {Object} params + * @param {import('@actions/github').GitHub} params.github - Authenticated GitHub client + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.username - GitHub username to check + * @returns {Promise} Whether the contributor is a team member + */ +const TEAM_PERMISSION_LEVELS = ['triage', 'write', 'maintain', 'admin']; + +const isTeam = async ({ + github, + owner, + repo, + username, +}) => { + try { + // Fetch the collaborator permission level for the user. + // This endpoint returns the user's highest permission + // within the repository. + const { data } = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + // Normalize permission for safe comparison. + const permission = + data?.permission?.toLowerCase() ?? 'none'; + + const isTeamMember = + TEAM_PERMISSION_LEVELS.includes(permission); + + console.log('[is-team] Permission check:', { + username, + permission, + isTeamMember, + }); + + return isTeamMember; + } catch (error) { + // 404 indicates the user is not a collaborator on the repo. + if (error?.status === 404) { + console.log('[is-team] User is not a collaborator', { + username, + }); + return false; + } + + // Any other error fails closed to avoid granting privileges. + console.log('[is-team] Permission lookup failed', { + username, + message: error.message, + }); + + return false; + } +}; + +module.exports = { + isTeam, +}; diff --git a/.github/workflows/bot-gfi-assign-on-comment.yml b/.github/workflows/bot-gfi-assign-on-comment.yml index 3bd35dbb6..254b28503 100644 --- a/.github/workflows/bot-gfi-assign-on-comment.yml +++ b/.github/workflows/bot-gfi-assign-on-comment.yml @@ -34,5 +34,5 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd #v8.0.0 with: script: | - const script = require('./.github/scripts/bot-gfi-assign-on-comment.js'); + const script = require('./.github/scripts/bots/assign-01-gfi-auto.js'); await script({ github, context }); diff --git a/.github/workflows/bot-advanced-check.yml b/.github/workflows/temp-archive/bot-advanced-check.yml similarity index 100% rename from .github/workflows/bot-advanced-check.yml rename to .github/workflows/temp-archive/bot-advanced-check.yml diff --git a/.github/workflows/bot-assignment-check.yml b/.github/workflows/temp-archive/bot-assignment-check.yml similarity index 100% rename from .github/workflows/bot-assignment-check.yml rename to .github/workflows/temp-archive/bot-assignment-check.yml diff --git a/.github/workflows/bot-inactivity-unassign-phase.yml b/.github/workflows/temp-archive/bot-inactivity-unassign-phase.yml similarity index 100% rename from .github/workflows/bot-inactivity-unassign-phase.yml rename to .github/workflows/temp-archive/bot-inactivity-unassign-phase.yml diff --git a/.github/workflows/bot-intermediate-assignment.yml b/.github/workflows/temp-archive/bot-intermediate-assignment.yml similarity index 100% rename from .github/workflows/bot-intermediate-assignment.yml rename to .github/workflows/temp-archive/bot-intermediate-assignment.yml diff --git a/.github/workflows/bot-issue-reminder-no-pr.yml b/.github/workflows/temp-archive/bot-issue-reminder-no-pr.yml similarity index 100% rename from .github/workflows/bot-issue-reminder-no-pr.yml rename to .github/workflows/temp-archive/bot-issue-reminder-no-pr.yml diff --git a/.github/workflows/bot-linked-issue-enforcer.yml b/.github/workflows/temp-archive/bot-linked-issue-enforcer.yml similarity index 100% rename from .github/workflows/bot-linked-issue-enforcer.yml rename to .github/workflows/temp-archive/bot-linked-issue-enforcer.yml diff --git a/.github/workflows/bot-mentor-assignment.yml b/.github/workflows/temp-archive/bot-mentor-assignment.yml similarity index 100% rename from .github/workflows/bot-mentor-assignment.yml rename to .github/workflows/temp-archive/bot-mentor-assignment.yml diff --git a/.github/workflows/bot-merge-conflict.yml b/.github/workflows/temp-archive/bot-merge-conflict.yml similarity index 100% rename from .github/workflows/bot-merge-conflict.yml rename to .github/workflows/temp-archive/bot-merge-conflict.yml diff --git a/.github/workflows/bot-office-hours.yml b/.github/workflows/temp-archive/bot-office-hours.yml similarity index 100% rename from .github/workflows/bot-office-hours.yml rename to .github/workflows/temp-archive/bot-office-hours.yml diff --git a/.github/workflows/bot-p0-issues-notify-team.yml b/.github/workflows/temp-archive/bot-p0-issues-notify-team.yml similarity index 100% rename from .github/workflows/bot-p0-issues-notify-team.yml rename to .github/workflows/temp-archive/bot-p0-issues-notify-team.yml diff --git a/.github/workflows/bot-pr-auto-draft-on-changes.yml b/.github/workflows/temp-archive/bot-pr-auto-draft-on-changes.yml similarity index 100% rename from .github/workflows/bot-pr-auto-draft-on-changes.yml rename to .github/workflows/temp-archive/bot-pr-auto-draft-on-changes.yml diff --git a/.github/workflows/bot-pr-inactivity-reminder.yml b/.github/workflows/temp-archive/bot-pr-inactivity-reminder.yml similarity index 100% rename from .github/workflows/bot-pr-inactivity-reminder.yml rename to .github/workflows/temp-archive/bot-pr-inactivity-reminder.yml diff --git a/.github/workflows/bot-pr-missing-linked-issue.yml b/.github/workflows/temp-archive/bot-pr-missing-linked-issue.yml similarity index 100% rename from .github/workflows/bot-pr-missing-linked-issue.yml rename to .github/workflows/temp-archive/bot-pr-missing-linked-issue.yml diff --git a/.github/workflows/bot-verified-commits.yml b/.github/workflows/temp-archive/bot-verified-commits.yml similarity index 100% rename from .github/workflows/bot-verified-commits.yml rename to .github/workflows/temp-archive/bot-verified-commits.yml diff --git a/.github/workflows/bot-workflows.yml b/.github/workflows/temp-archive/bot-workflows.yml similarity index 100% rename from .github/workflows/bot-workflows.yml rename to .github/workflows/temp-archive/bot-workflows.yml diff --git a/.github/workflows/cron-check-broken-links.yml b/.github/workflows/temp-archive/cron-check-broken-links.yml similarity index 100% rename from .github/workflows/cron-check-broken-links.yml rename to .github/workflows/temp-archive/cron-check-broken-links.yml diff --git a/.github/workflows/pr-check-broken-links.yml b/.github/workflows/temp-archive/pr-check-broken-links.yml similarity index 100% rename from .github/workflows/pr-check-broken-links.yml rename to .github/workflows/temp-archive/pr-check-broken-links.yml diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/temp-archive/pr-check-changelog.yml similarity index 100% rename from .github/workflows/pr-check-changelog.yml rename to .github/workflows/temp-archive/pr-check-changelog.yml diff --git a/.github/workflows/pr-check-codecov.yml b/.github/workflows/temp-archive/pr-check-codecov.yml similarity index 100% rename from .github/workflows/pr-check-codecov.yml rename to .github/workflows/temp-archive/pr-check-codecov.yml diff --git a/.github/workflows/pr-check-examples.yml b/.github/workflows/temp-archive/pr-check-examples.yml similarity index 100% rename from .github/workflows/pr-check-examples.yml rename to .github/workflows/temp-archive/pr-check-examples.yml diff --git a/.github/workflows/pr-check-test-files.yml b/.github/workflows/temp-archive/pr-check-test-files.yml similarity index 100% rename from .github/workflows/pr-check-test-files.yml rename to .github/workflows/temp-archive/pr-check-test-files.yml diff --git a/.github/workflows/pr-check-test.yml b/.github/workflows/temp-archive/pr-check-test.yml similarity index 100% rename from .github/workflows/pr-check-test.yml rename to .github/workflows/temp-archive/pr-check-test.yml diff --git a/.github/workflows/pr-check-title.yml b/.github/workflows/temp-archive/pr-check-title.yml similarity index 100% rename from .github/workflows/pr-check-title.yml rename to .github/workflows/temp-archive/pr-check-title.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/temp-archive/publish.yml similarity index 100% rename from .github/workflows/publish.yml rename to .github/workflows/temp-archive/publish.yml