From c579420cf4a76064de5e1f041e67bd6aaef43c9d Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Thu, 16 Jul 2026 22:37:39 +0530 Subject: [PATCH 01/11] refactor: consolidate issue assignments script Signed-off-by: Parv Ninama --- .../scripts/bot-beginner-assign-on-comment.js | 404 ---------------- .github/scripts/bot-gfi-assign-on-comment.js | 436 ------------------ .../scripts/bot-issue-assign-on-comment.js | 43 ++ .github/scripts/shared/core/issue-assign.js | 348 ++++++++++++++ 4 files changed, 391 insertions(+), 840 deletions(-) delete mode 100644 .github/scripts/bot-beginner-assign-on-comment.js delete mode 100644 .github/scripts/bot-gfi-assign-on-comment.js create mode 100644 .github/scripts/bot-issue-assign-on-comment.js create mode 100644 .github/scripts/shared/core/issue-assign.js diff --git a/.github/scripts/bot-beginner-assign-on-comment.js b/.github/scripts/bot-beginner-assign-on-comment.js deleted file mode 100644 index 866be76c3..000000000 --- a/.github/scripts/bot-beginner-assign-on-comment.js +++ /dev/null @@ -1,404 +0,0 @@ -/* -============================================================================== -Executes When: - - Triggered by GitHub Actions workflow on event: 'issue_comment' (created). - - Target: Issues specifically labeled with "beginner". - -Goal: - It acts as an automated onboarding assistant for "beginner" issues. It allows - contributors to self-assign using a command and nudges new contributors who - express interest but forget to assign themselves, while preventing spam. - ------------------------------------------------------------------------------- -Flow: Basic Idea - 1. Listens for comments on issues. - 2. Ignores Pull Requests, Bots, and issues missing the "beginner" label. - 3. Detects if the user typed "/assign". - - If YES: Assigns the user to the issue (if currently unassigned). - - If NO: Checks if the user is an external contributor expressing interest. - If so, it replies with instructions on how to use the assign command. - ------------------------------------------------------------------------------- -Flow: Detailed Technical Steps - -1️⃣ Validation & filtering - - Checks payload to ensure it is an Issue Comment (not a PR). - - Checks if the commenter is a BOT (e.g., github-actions). If so, exits. - - Checks if the issue has the specific label "beginner". If not, exits. - -2️⃣ Collaborator Check (isRepoCollaborator) - - Action: Checks if the user is a repository collaborator. - - Logic: - * Collaborator (204) -> Treated as Team Member (Bot ignores them). - * Non-Collaborator (404) -> Treated as External Contributor (Bot helps them). - -3️⃣ Logic Branch A: The "/assign" Command - - Trigger: User comment matches regex /(^|\s)\/assign(\s|$)/i. - - Check: Is the issue already assigned? - * Yes -> Alert the user that it is taken. - * No -> API Call: Add commenter to 'assignees'. - -4️⃣ Logic Branch B: The Helper Reminder - - Trigger: Generic comment (e.g., "I want to work on this"). - - Condition 1: Issue must be unassigned. - - Condition 2: Commenter must NOT be a Repo Collaborator. - - Condition 3: Duplicate Check. - * Scans previous comments for a hidden marker: "". - * If found -> Exits to avoid spamming the thread. - - Action: Posts a comment with the hidden marker and instructions. - ------------------------------------------------------------------------------- -Parameters: - - { github, context }: Standard objects provided by 'actions/github-script'. -============================================================================== -*/ - -const fs = require("fs"); -const { BEGINNER_LABEL } = require('./shared/labels.js'); - -const SPAM_LIST_PATH = ".github/spam-list.txt"; -const REQUIRED_GFI_COUNT = 1; -const GFI_LABEL = 'Good First Issue'; -const BEGINNER_GUARD_MARKER = ''; - -function isSafeSearchToken(value) { - return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value); -} - -async function countCompletedGfiIssues(github, owner, repo, username) { - if ( - !isSafeSearchToken(owner) || - !isSafeSearchToken(repo) || - !isSafeSearchToken(username) - ) { - return null; - } - - const searchQuery = [ - `repo:${owner}/${repo}`, - `label:"${GFI_LABEL}"`, - 'is:issue', - 'is:closed', - `assignee:${username}`, - ].join(' '); - - const result = await github.graphql( - ` - query ($searchQuery: String!) { - search(type: ISSUE, query: $searchQuery) { - issueCount - } - } - `, - { searchQuery } - ); - - return result?.search?.issueCount ?? 0; -} - -module.exports = async ({ github, context }) => { - try { - const { payload } = context; - const issue = payload.issue; - const comment = payload.comment; - const repo = payload.repository; - - // 1. Basic Validation - if (!issue || !comment || !repo || issue.pull_request) { - console.log("[Beginner Bot] Invalid payload or PR comment. Exiting."); - return; - } - - // 1.1 Bot Check (Fix 2: Defensive Check) - if (comment.user?.type === "Bot") { - console.log(`[Beginner Bot] Commenter @${comment.user.login} is a bot. Exiting.`); - return; - } - - // 2. Label Check (Fix 2: Defensive Check) - const beginnerLabel = BEGINNER_LABEL.toLowerCase(); - const hasBeginnerLabel = Array.isArray(issue.labels) && issue.labels.some((label) => label.name?.toLowerCase() === beginnerLabel); - if (!hasBeginnerLabel) { - console.log(`[Beginner Bot] Issue #${issue.number} does not have '${BEGINNER_LABEL}' label. Exiting.`); - return; - } - - // 3. Collaborator Check Helper - async function isRepoCollaborator(username) { - try { - if (username === repo.owner.login) { - console.log(`[Beginner Bot] User @${username} is the repo owner.`); - return true; - } - - await github.rest.repos.checkCollaborator({ - owner: repo.owner.login, - repo: repo.name, - username: username, - }); - console.log(`[Beginner Bot] User @${username} is a confirmed repo collaborator.`); - return true; - } catch (error) { - if (error.status === 404) { - console.log(`[Beginner Bot] User @${username} is NOT a collaborator (External Contributor).`); - return false; - } - console.log(`[Beginner Bot] Error checking collaborator status for @${username}: ${error.message}`); - return false; - } - } - - // NEW: Spam + Assignment Limit Helpers (Layered In) - - function isSpamUser(username) { - if (!fs.existsSync(SPAM_LIST_PATH)) return false; - - const list = fs.readFileSync(SPAM_LIST_PATH, "utf8") - .split("\n") - .map(l => l.trim()) - .filter(l => l && !l.startsWith("#")); - - return list.includes(username); - } - - async function getOpenAssignments(username) { - const issues = await github.paginate( - github.rest.issues.listForRepo, - { - owner: repo.owner.login, - repo: repo.name, - assignee: username, - state: "open", - per_page: 100, - } - ); - return issues.length; - } - - const commenter = comment.user.login; - - // Fix 3: Validate comment body - if (!comment.body) { - console.log("[Beginner Bot] Comment body is empty. Exiting."); - return; - } - - const commentBody = comment.body.toLowerCase(); - const isAssignCommand = /(^|\s)\/assign(\s|$)/i.test(commentBody); - - // 4. Logic Branch - if (isAssignCommand) { - const completedGfiCount = await countCompletedGfiIssues( - github, - repo.owner.login, - repo.name, - commenter - ); - - console.log("[Beginner Bot] Completed GFI count:",{ - commenter, - completedGfiCount, - }) - - if (completedGfiCount === null) { - console.log("[Beginner Bot] Skipping GFI guard due to API error."); - } else if (completedGfiCount < REQUIRED_GFI_COUNT) { - - let allComments = []; - try { - allComments = await github.paginate( - github.rest.issues.listComments, - { - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - per_page: 100, - } - ); - } catch (error) { - console.error("[Beginner Bot] Failed to fetch comments for GFI guard:", { - issue: issue.number, - commenter, - message: error.message, - }); - return; - } - - const guardAlreadyPosted = allComments.some((c) => - c.body?.includes(BEGINNER_GUARD_MARKER) - ); - - if (!guardAlreadyPosted) { - try{ - await github.rest.issues.createComment({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - body: `${BEGINNER_GUARD_MARKER} -👋 Hi @${commenter}! Thanks for your interest in contributing 💡 - -Before taking on a **beginner** issue, we ask contributors to complete at least one **Good First Issue** to get familiar with the workflow. - -👉 [Find a Good First Issue here](https://github.com/${repo.owner.login}/${repo.name}/issues?q=is%3Aissue+is%3Aopen+label%3A%22Good+First+Issue%22+no%3Aassignee) - -Please try a GFI first, then come back — we’ll be happy to assign this! 😊`, - }); - console.log("[Beginner Bot] GFI guard comment posted."); - } catch(error){ - console.error("[Beginner Bot] Failed to post GFI guard comment:",{ - issue: issue.number, - commenter, - message: error.message, - }); - } - } - return; - } - - // --- ASSIGNMENT LOGIC --- - if (issue.assignees && issue.assignees.length > 0) { - try{ - const currentAssignee = issue.assignees[0]?.login ?? "another contributor"; - await github.rest.issues.createComment({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - body: `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to @${currentAssignee}, but we'd love your help on another one. You can find more "${BEGINNER_LABEL}" issues [here](https://github.com/${repo.owner.login}/${repo.name}/issues?q=is%3Aissue+is%3Aopen+label%3A%22${encodeURIComponent(BEGINNER_LABEL)}%22+no%3Aassignee).`, - }); - } catch (error) { - console.error("[Beginner Bot] Failed to post already-assigned message:", { - issue: issue.number, - commenter, - message: error.message, - }); - } - return; - } - - // Block spam users from beginner issues - - const spamUser = isSpamUser(commenter); - - if (spamUser) { - console.log(`[Beginner Bot] Spam user @${commenter} attempted to assign to beginner issue. Blocked.`); - - try { - await github.rest.issues.createComment({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - body: `Hi @${commenter}, your account is currently restricted to **Good First Issues**. Please complete a Good First Issue or contact a maintainer to have restrictions reviewed.`, - }); - } catch (error) { - console.error(`[Beginner Bot] Failed to post spam restriction message: ${error.message}`); - } - - return; - } - - // Enforce Assignment Limits - - const openCount = await getOpenAssignments(commenter); - const maxAllowed = 2; - - console.log("[Beginner Bot] Limit check:", { - commenter, - openCount, - spamUser, - maxAllowed, - }); - - if (openCount >= maxAllowed) { - const message = `👋 Hi @${commenter}, you already have **2 open assignments**. Please finish one before requesting another.`; - - try { - await github.rest.issues.createComment({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - body: message, - }); - } catch (error) { - console.error(`[Beginner Bot] Failed to post limit warning: ${error.message}`); - } - - return; - } - - console.log(`[Beginner Bot] Assigning issue #${issue.number} to @${commenter}...`); - - // Fix 4: Granular Try/Catch for Assign API - try { - await github.rest.issues.addAssignees({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - assignees: [commenter], - }); - console.log(`[Beginner Bot] Successfully assigned.`); - } catch (error) { - console.error(`[Beginner Bot] Failed to assign issue: ${error.message}`); - } - - } else { - // --- REMINDER LOGIC --- - - if (issue.assignees && issue.assignees.length > 0) { - console.log(`[Beginner Bot] Issue #${issue.number} is already assigned. Skipping reminder.`); - return; - } - - if (await isRepoCollaborator(commenter)) { - console.log(`[Beginner Bot] Commenter @${commenter} is a repo collaborator. Skipping reminder.`); - return; - } - - // Fix 5: Updated Marker Text - const REMINDER_MARKER = ""; - // FIX 6: Granular Try/Catch for List Comments API - let comments; - try { - const { data } = await github.rest.issues.listComments({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - }); - comments = data; - } catch (error) { - console.error(`[Beginner Bot] Failed to list comments: ${error.message}`); - return; // Exit gracefully if we can't check for duplicates - } - - if (comments.some((c) => c.body.includes(REMINDER_MARKER))) { - console.log("[Beginner Bot] Reminder already exists on this issue. Skipping."); - return; - } - - console.log(`[Beginner Bot] Posting help reminder for @${commenter}...`); - - const reminderBody = `${REMINDER_MARKER}\n👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; - - // FIX 6: Granular Try/Catch for Create Comment API - try { - await github.rest.issues.createComment({ - owner: repo.owner.login, - repo: repo.name, - issue_number: issue.number, - body: reminderBody, - }); - console.log("[Beginner Bot] Reminder posted successfully."); - } catch (error) { - console.error(`[Beginner Bot] Failed to post reminder: ${error.message}`); - } - } - - } catch (error) { - // Fix 1: Top-level error handling - console.error("[Beginner Bot] Unexpected error:", { - message: error.message, - status: error.status, - issue: context.payload?.issue?.number, - comment: context.payload?.comment?.id - }); - } -}; diff --git a/.github/scripts/bot-gfi-assign-on-comment.js b/.github/scripts/bot-gfi-assign-on-comment.js deleted file mode 100644 index 95328e603..000000000 --- a/.github/scripts/bot-gfi-assign-on-comment.js +++ /dev/null @@ -1,436 +0,0 @@ -// .github/scripts/bot-gfi_assign_on_comment.js -// -// Assigns human user to Good First Issue when they comment "/assign". -// Posts a comment if the issue is already assigned. -// All other validation and additional GFI comments are handled by other existing bots which can be refactored with time. - -const fs = require('fs'); // For spam list - -const { GOOD_FIRST_ISSUE_LABEL } = require('./shared/labels.js'); -const UNASSIGNED_GFI_SEARCH_URL = - `https://github.com/hiero-ledger/hiero-sdk-python/issues?q=${encodeURIComponent(`is:issue state:open label:"${GOOD_FIRST_ISSUE_LABEL}" no:assignee`)}`; - -const SPAM_LIST_PATH = '.github/spam-list.txt'; - -/// -------------------- -/// NEW HELPERS (LIMIT ENFORCEMENT) -/// -------------------- - -function isSpamUser(username) { - if (!fs.existsSync(SPAM_LIST_PATH)) return false; - - const list = fs.readFileSync(SPAM_LIST_PATH, 'utf8') - .split('\n') - .map(l => l.trim()) - .filter(l => l && !l.startsWith('#')); - - return list.includes(username); -} - -async function getOpenAssignments({ github, owner, repo, username }) { - const issues = await github.paginate( - github.rest.issues.listForRepo, - { - owner, - repo, - assignee: username, - state: 'open', - per_page: 100, - } - ); - return issues.length; -} - -/// HELPERS FOR ASSIGNING /// - -/** - * Returns true if /assign appears as a standalone command in the comment - */ -function commentRequestsAssignment(body) { - const matches = - typeof body === 'string' && - /(^|\s)\/assign(\s|$)/i.test(body); - - console.log('[gfi-assign] commentRequestsAssignment:', { - body, - matches, - }); - - return matches; -} - -/** - * Returns true if the issue has the Good First Issue label. - */ -function issueIsGoodFirstIssue(issue) { - const labels = issue?.labels?.map(label => label.name) ?? []; - const isGfi = labels.some(label => - typeof label === 'string' && label.toLowerCase() === GOOD_FIRST_ISSUE_LABEL.toLowerCase() - ); - - console.log('[gfi-assign] issueIsGoodFirstIssue:', { - labels, - expected: GOOD_FIRST_ISSUE_LABEL, - isGfi, - }); - - return isGfi; -} -/// HELPERS FOR COMMENTING /// - -/** - * Returns a formatted @username for the current assignee of the issue. - */ -function getCurrentAssigneeMention(issue) { - const login = issue?.assignees?.[0]?.login; - return login ? `@${login}` : 'someone'; -} - -/** - * Builds a comment explaining that the issue is already assigned. - * Requester username is passed from main - */ -function commentAlreadyAssigned(requesterUsername, issue) { - return ( - `Hi @${requesterUsername} — this issue is already assigned to ${getCurrentAssigneeMention(issue)}, so I can’t assign it again. - -👉 **Choose a different Good First Issue to work on next:** -[Browse unassigned Good First Issues](${UNASSIGNED_GFI_SEARCH_URL}) - -Once you find one you like, comment \`/assign\` to get started.` - ); -} - -// HELPERS FOR PEOPLE THAT WANT TO BE ASSIGNED BUT DID NOT READ INSTRUCTIONS -const ASSIGN_REMINDER_MARKER = ''; - -function buildAssignReminder(username) { - return `${ASSIGN_REMINDER_MARKER} -👋 Hi @${username}! - -If you’d like to work on this **Good First Issue**, just comment: - -\`\`\` -/assign -\`\`\` - -and you’ll be automatically assigned. Feel free to ask questions here if anything is unclear!`; -} - -/// HELPERS TO DETECT COLLABORATORS /// - -function hasValidInputs({ github, owner, repo, username }) { - return Boolean( - github && - typeof owner === 'string' && - typeof repo === 'string' && - typeof username === 'string' && - owner && - repo && - username - ); -} - -function isPermissionFailure(error) { - return error?.status === 401 || error?.status === 403; -} - -async function isRepoCollaborator({ github, owner, repo, username }) { - if (!hasValidInputs({ github, owner, repo, username })) { - console.log('[gfi-assign] isRepoCollaborator: invalid args', { - owner, - repo, - username, - }); - return false; - } - - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username, - }); - - const permission = response?.data?.permission; - - const isTeamMember = - permission === 'admin' || - permission === 'write' || - permission === 'maintain' || - permission === 'read'; - - console.log('[gfi-assign] isRepoCollaborator:', { - username, - permission, - isTeamMember, - }); - - return isTeamMember; - } catch (error) { - if (isPermissionFailure(error) || error?.status === 404) { - console.log( - '[gfi-assign] isRepoCollaborator: no permission / not collaborator', - { username, status: error.status } - ); - return false; - } - throw error; - } -} - - - -/// START OF SCRIPT /// -module.exports = async ({ github, context }) => { - try { - const { issue, comment } = context.payload; - const { owner, repo } = context.repo; - - console.log('[gfi-assign] Payload snapshot:', { - issueNumber: issue?.number, - commenter: comment?.user?.login, - commenterType: comment?.user?.type, - commentBody: comment?.body, - }); - - // Reject if issue, comment or comment user is missing, reject bots, or if no /assign message - if (!issue?.number) { - console.log('[gfi-assign] Exit: missing issue number'); - return; - } - - if (!comment?.body) { - console.log('[gfi-assign] Exit: missing comment body'); - return; - } - - if (!comment?.user?.login) { - console.log('[gfi-assign] Exit: missing comment user login'); - return; - } - - if (comment.user.type === 'Bot') { - console.log('[gfi-assign] Exit: comment authored by bot'); - return; - } - - if (!commentRequestsAssignment(comment.body)) { - console.log('[gfi-assign] No /assign command - evaluating reminder criteria', { - issueNumber: issue?.number, - commenter: comment?.user?.login, - }); - - const isGFI = issueIsGoodFirstIssue(issue); - const assigneeCount = issue?.assignees?.length ?? 0; - const hasAssignees = assigneeCount > 0; - const commenter = comment?.user?.login; - - console.log('[gfi-assign] Reminder criteria summary:', { - isGFI, - hasAssignees, - assigneeCount, - commenter, - issueNumber: issue?.number, - }); - - // Only remind if: - // - GFI - // - unassigned - // - reminder not already posted - if (isGFI && !hasAssignees) { - const username = comment.user.login; - - const isTeamMember = await isRepoCollaborator({ - github, - owner, - repo, - username, - }); - - if (isTeamMember) { - console.log('[gfi-assign] Skip reminder: commenter is collaborator'); - return; - } - - 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) - ); - - if (!reminderAlreadyPosted) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: buildAssignReminder(comment.user.login), - }); - - console.log('[gfi-assign] Posted /assign reminder'); - } else { - console.log('[gfi-assign] Skip reminder: already posted on this issue'); - } - } else { - console.log('[gfi-assign] Skip reminder: not GFI or already assigned', { - isGFI, - hasAssignees, - assigneeCount, - }); - } - - console.log('[gfi-assign] Exit: comment does not request assignment'); - return; - } - - console.log('[gfi-assign] Assignment command detected'); - - // Reject if issue is not a Good First Issue - if (!issueIsGoodFirstIssue(issue)) { - console.log('[gfi-assign] Exit: issue is not a Good First Issue'); - return; - } - - console.log('[gfi-assign] Issue is labeled Good First Issue'); - - // Get requester username and issue number to enable comments and assignments - const requesterUsername = comment.user.login; - const issueNumber = issue.number; - - console.log('[gfi-assign] Requester:', requesterUsername); - console.log('[gfi-assign] Current assignees:', issue.assignees?.map(a => a.login)); - - // Reject if issue is already assigned - // Comment failure to the requester - if (issue.assignees?.length > 0) { - console.log('[gfi-assign] Exit: issue already assigned'); - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: commentAlreadyAssigned(requesterUsername, issue), - }); - - console.log('[gfi-assign] Posted already-assigned comment'); - return; - } - - // ------------------------------- - // ENFORCE ASSIGNMENT LIMITS - // ------------------------------- - - const openCount = await getOpenAssignments({ - github, - owner, - repo, - username: requesterUsername, - }); - - const spamUser = isSpamUser(requesterUsername); - const maxAllowed = spamUser ? 1 : 2; - - console.log('[gfi-assign] Limit check:', { - requesterUsername, - openCount, - spamUser, - maxAllowed, - }); - - if (openCount >= maxAllowed) { - const message = spamUser - ? `Hi @${requesterUsername}, you are limited to **1 active issue** at a time. Please complete your current assignment before requesting another.` - : `Hi @${requesterUsername}, you already have **2 open assignments**. Please finish one before requesting another.`; - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: message, - }); - - console.log('[gfi-assign] Assignment blocked due to limit'); - return; - } - - console.log('[gfi-assign] Assigning issue to requester'); - - // All validations passed and user has requested assignment on a GFI - // Assign the issue to the requester - // Do not comment on success - await github.rest.issues.addAssignees({ - owner, - repo, - issue_number: issueNumber, - assignees: [requesterUsername], - }); - - console.log('[gfi-assign] Assignment completed successfully'); - - // Chain mentor assignment after successful GFI assignment - let mentorAssignmentSucceeded = false; - try { - const assignMentor = require('./bot-mentor-assignment.js'); - await assignMentor({ - github, - context, - assignee: { login: requesterUsername, type: 'User' } // Pass freshly-assigned username - }); - mentorAssignmentSucceeded = true; - console.log('[gfi-assign] Mentor assignment chained successfully'); - } catch (error) { - console.error('[gfi-assign] Mentor assignment failed but user assignment succeeded:', { - message: error.message, - status: error.status, - owner, - repo, - issueNumber, - assignee: requesterUsername, - }); - // Don't throw error - user assignment was successful - } - - // Chain CodeRabbit plan trigger after mentor assignment - // Only trigger if mentor assignment succeeded to maintain the expected flow - if (mentorAssignmentSucceeded) { - try { - const { triggerCodeRabbitPlan, hasExistingCodeRabbitPlan } = require('./coderabbit_plan_trigger.js'); - - // Check if CodeRabbit plan already exists to avoid duplicate comments - const planExists = await hasExistingCodeRabbitPlan(github, owner, repo, issueNumber); - if (planExists) { - console.log('[gfi-assign] CodeRabbit plan already exists, skipping'); - } else { - await triggerCodeRabbitPlan(github, owner, repo, issue); - console.log('[gfi-assign] CodeRabbit plan chained successfully'); - } - } catch (error) { - console.error('[gfi-assign] CodeRabbit plan failed but user assignment succeeded:', { - message: error.message, - status: error.status, - owner, - repo, - issueNumber, - assignee: requesterUsername, - }); - // Don't throw error - user assignment was successful - } - } - } catch (error) { - console.error('[gfi-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-issue-assign-on-comment.js b/.github/scripts/bot-issue-assign-on-comment.js new file mode 100644 index 000000000..9d795ae45 --- /dev/null +++ b/.github/scripts/bot-issue-assign-on-comment.js @@ -0,0 +1,43 @@ +// .github/scripts/bot-issue-assign-on-comment.js +// +// Replaces bot-beginner-assign-on-comment.js and bot-gfi_assign_on_comment.js. +// +// Triggered by the `issue_comment` (created) event. Figures out which +// difficulty level (if any) applies to the commented-on issue, then hands +// off to the shared engine with that level's config. All label-specific +// behavior lives in .github/scripts/configs/assignment-levels.js; this file +// is just wiring. +// +// Supporting a new difficulty level (e.g. "advanced") requires zero changes +// here — just add/enable its config in assignment-levels.js. + +const { runAssignmentFlow, issueHasLabel } = require('./shared/issue-assign-core.js'); +const { LEVEL_CONFIGS } = require('./configs/assignment-levels.js'); + +module.exports = async ({ github, context }) => { + try { + const issue = context.payload.issue; + + if (!issue) { + console.log('[assign-bot] No issue in payload. Exiting.'); + return; + } + + const levelConfig = LEVEL_CONFIGS.find((config) => issueHasLabel(issue, config.label)); + + if (!levelConfig) { + console.log(`[assign-bot] Issue #${issue.number} has no recognized difficulty label. Exiting.`); + return; + } + + console.log(`[assign-bot] Issue #${issue.number} matched level "${levelConfig.label}".`); + await runAssignmentFlow({ github, context, levelConfig }); + } catch (error) { + console.error('[assign-bot] Unexpected error:', { + message: error.message, + status: error.status, + issue: context.payload?.issue?.number, + comment: context.payload?.comment?.id, + }); + } +}; diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js new file mode 100644 index 000000000..a93884bfb --- /dev/null +++ b/.github/scripts/shared/core/issue-assign.js @@ -0,0 +1,348 @@ +// .github/scripts/shared/issue-assign-core.js +// +// Shared engine for "/assign"-style comment automation across every +// difficulty level (beginner, Good First Issue, intermediate, advanced, ...). +// +// This file intentionally contains NO per-label copy or behavior — that all +// lives in .github/scripts/configs/assignment-levels.js. This module only +// knows how to run the generic flow: +// +// 1. Validate the webhook payload (real issue comment, not a bot, not a PR) +// 2. Confirm the issue carries the label this config cares about +// 3. If the comment is a plain "I'd like to help" message with no issue +// assigned yet -> maybe post a one-time reminder to use `/assign` +// 4. If the comment contains `/assign`: +// a. Enforce any prerequisite (e.g. "complete 1 GFI before taking a +// beginner issue") — post a one-time guard comment if unmet +// b. Refuse (with a comment) if the issue is already assigned +// c. Enforce spam-list handling (hard block, or reduced limit) +// d. Enforce the open-assignment cap +// e. Assign the commenter +// f. Run any post-assignment chain (mentor assignment, CodeRabbit +// plan trigger, etc.) — each step is independent and non-fatal +// +// Adding a new difficulty level should never require touching this file. + +const fs = require('fs'); + +function isSafeSearchToken(value) { + return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value); +} + +function commentRequestsAssignment(body) { + return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); +} + +function issueHasLabel(issue, label) { + const target = label.toLowerCase(); + return Array.isArray(issue.labels) && issue.labels.some((l) => l.name?.toLowerCase() === target); +} + +function getCurrentAssigneeMention(issue) { + const login = issue?.assignees?.[0]?.login; + return login ? `@${login}` : 'someone'; +} + +function isSpamUser(username, spamListPath) { + if (!spamListPath || !fs.existsSync(spamListPath)) return false; + + const list = fs + .readFileSync(spamListPath, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')); + + return list.includes(username); +} + +async function getOpenAssignments({ github, owner, repo, username }) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + assignee: username, + state: 'open', + per_page: 100, + }); + return issues.length; +} + +/** + * Counts closed issues carrying `label` that were assigned to `username`. + * Used to enforce prerequisites like "finish 1 GFI before taking a beginner + * issue". Returns null (rather than throwing) on unsafe input or API error + * so callers can choose to fail open. + */ +async function countCompletedIssuesWithLabel({ github, owner, repo, username, label }) { + if (!isSafeSearchToken(owner) || !isSafeSearchToken(repo) || !isSafeSearchToken(username)) { + return null; + } + + const searchQuery = [ + `repo:${owner}/${repo}`, + `label:"${label}"`, + 'is:issue', + 'is:closed', + `assignee:${username}`, + ].join(' '); + + try { + const result = await github.graphql( + ` + query ($searchQuery: String!) { + search(type: ISSUE, query: $searchQuery) { + issueCount + } + } + `, + { searchQuery } + ); + return result?.search?.issueCount ?? 0; + } catch (error) { + console.error('[assign-bot] countCompletedIssuesWithLabel failed:', { + owner, + repo, + username, + label, + message: error.message, + }); + return null; + } +} + +async function isRepoCollaborator({ github, owner, repo, username }) { + if (username === owner) { + console.log(`[assign-bot] @${username} is the repo owner — treated as collaborator.`); + return true; + } + + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + const permission = response?.data?.permission; + const isTeamMember = ['admin', 'write', 'maintain', 'read'].includes(permission); + console.log('[assign-bot] isRepoCollaborator:', { username, permission, isTeamMember }); + return isTeamMember; + } catch (error) { + if (error?.status === 401 || error?.status === 403 || error?.status === 404) { + console.log('[assign-bot] isRepoCollaborator: not a collaborator', { username, status: error.status }); + return false; + } + // Unexpected error talking to the API — fail closed (don't treat as collaborator) + // but don't let it crash the whole run. + console.error('[assign-bot] isRepoCollaborator: unexpected error', { username, message: error.message }); + return false; + } +} + +async function postComment({ github, owner, repo, issueNumber, body }, logLabel) { + try { + await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); + console.log(`[assign-bot] Posted comment: ${logLabel}`); + } catch (error) { + console.error(`[assign-bot] Failed to post comment (${logLabel}):`, { message: error.message }); + } +} + +async function fetchAllComments({ github, owner, repo, issueNumber }) { + return github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); +} + +/** + * Runs chained post-assignment steps in order. Each step is: + * { name: string, requiresPrevious?: boolean, run: async (ctx) => void } + * A step throwing is caught and logged; it never fails the overall run + * (the human assignment has already succeeded by this point). Steps with + * requiresPrevious=true are skipped if the prior step failed, mirroring the + * original "only trigger CodeRabbit if mentor assignment succeeded" logic. + */ +async function runPostAssignChain(chain, ctx) { + let previousSucceeded = true; + for (const step of chain || []) { + if (step.requiresPrevious && !previousSucceeded) { + console.log(`[assign-bot] Skipping chained step "${step.name}" (previous step failed).`); + continue; + } + try { + await step.run(ctx); + previousSucceeded = true; + console.log(`[assign-bot] Chained step "${step.name}" succeeded.`); + } catch (error) { + previousSucceeded = false; + console.error(`[assign-bot] Chained step "${step.name}" failed:`, { + message: error.message, + status: error.status, + }); + } + } +} + +/** + * Main entry point. `levelConfig` describes everything level-specific; + * see configs/assignment-levels.js for the shape and examples. + */ +async function runAssignmentFlow({ github, context, levelConfig }) { + const { payload } = context; + const issue = payload.issue; + const comment = payload.comment; + const repo = payload.repository; + + if (!issue || !comment || !repo || issue.pull_request) { + console.log('[assign-bot] Invalid payload or PR comment. Exiting.'); + return; + } + + if (comment.user?.type === 'Bot') { + console.log(`[assign-bot] Commenter @${comment.user?.login} is a bot. Exiting.`); + return; + } + + if (!comment.body) { + console.log('[assign-bot] Comment body is empty. Exiting.'); + return; + } + + if (!issueHasLabel(issue, levelConfig.label)) { + console.log(`[assign-bot] Issue #${issue.number} does not have the "${levelConfig.label}" label. Exiting.`); + return; + } + + const owner = repo.owner.login; + const repoName = repo.name; + const commenter = comment.user.login; + const issueNumber = issue.number; + const isAssigned = Array.isArray(issue.assignees) && issue.assignees.length > 0; + + // ---- Branch A: plain comment, no /assign — maybe post a reminder ---- + if (!commentRequestsAssignment(comment.body)) { + if (isAssigned) { + console.log(`[assign-bot] Issue #${issueNumber} already assigned. Skipping reminder.`); + return; + } + + if (await isRepoCollaborator({ github, owner, repo: repoName, username: commenter })) { + console.log(`[assign-bot] @${commenter} is a collaborator. Skipping reminder.`); + return; + } + + let comments; + try { + comments = await fetchAllComments({ github, owner, repo: repoName, issueNumber }); + } catch (error) { + console.error('[assign-bot] Failed to list comments for reminder check:', { message: error.message }); + return; + } + + const reminderAlreadyPosted = comments.some((c) => c.body?.includes(levelConfig.reminderMarker)); + if (reminderAlreadyPosted) { + console.log('[assign-bot] Reminder already posted. Skipping.'); + return; + } + + const body = `${levelConfig.reminderMarker}\n${levelConfig.reminderMessageBuilder(commenter)}`; + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'assign reminder'); + return; + } + + // ---- Branch B: /assign command ---- + + // Prerequisite gate (e.g. "must complete 1 GFI before taking a beginner issue") + if (levelConfig.prerequisite) { + const { label: prereqLabel, count: requiredCount, guardMarker, guardMessageBuilder } = levelConfig.prerequisite; + + const completedCount = await countCompletedIssuesWithLabel({ + github, + owner, + repo: repoName, + username: commenter, + label: prereqLabel, + }); + + console.log('[assign-bot] Prerequisite check:', { commenter, prereqLabel, requiredCount, completedCount }); + + if (completedCount === null) { + console.log('[assign-bot] Skipping prerequisite guard due to API error (fail open).'); + } else if (completedCount < requiredCount) { + let comments; + try { + comments = await fetchAllComments({ github, owner, repo: repoName, issueNumber }); + } catch (error) { + console.error('[assign-bot] Failed to list comments for guard check:', { message: error.message }); + return; + } + + const guardAlreadyPosted = comments.some((c) => c.body?.includes(guardMarker)); + if (!guardAlreadyPosted) { + const body = `${guardMarker}\n${guardMessageBuilder(commenter, { owner, repo: repoName })}`; + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'prerequisite guard'); + } + return; + } + } + + // Already assigned? + if (isAssigned) { + const body = levelConfig.alreadyAssignedMessageBuilder(commenter, issue, { owner, repo: repoName }); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'already-assigned notice'); + return; + } + + // Spam handling + const spamConfig = levelConfig.spam; + const spamUser = spamConfig ? isSpamUser(commenter, spamConfig.listPath) : false; + + if (spamUser && spamConfig.behavior === 'block') { + console.log(`[assign-bot] Spam user @${commenter} blocked from "${levelConfig.label}" issues.`); + const body = spamConfig.blockedMessageBuilder(commenter); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'spam restriction notice'); + return; + } + + // Open-assignment cap (spam users may get a reduced cap instead of a hard block) + const maxAllowed = + spamUser && spamConfig?.behavior === 'limit' ? spamConfig.limitTo : levelConfig.maxOpenAssignments; + + const openCount = await getOpenAssignments({ github, owner, repo: repoName, username: commenter }); + console.log('[assign-bot] Limit check:', { commenter, openCount, spamUser, maxAllowed }); + + if (openCount >= maxAllowed) { + const body = levelConfig.limitMessageBuilder(commenter, { maxAllowed, spamLimited: spamUser && spamConfig?.behavior === 'limit' }); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'limit warning'); + return; + } + + // Assign + try { + await github.rest.issues.addAssignees({ owner, repo: repoName, issue_number: issueNumber, assignees: [commenter] }); + console.log(`[assign-bot] Assigned #${issueNumber} to @${commenter}.`); + } catch (error) { + console.error('[assign-bot] Failed to assign issue:', { message: error.message }); + return; + } + + // Post-assignment chain (mentor assignment, CodeRabbit plan, etc.) + await runPostAssignChain(levelConfig.postAssignChain, { + github, + context, + owner, + repo: repoName, + issue, + issueNumber, + assignee: { login: commenter, type: 'User' }, + }); +} + +module.exports = { + runAssignmentFlow, + isSafeSearchToken, + commentRequestsAssignment, + issueHasLabel, + getCurrentAssigneeMention, + isSpamUser, + getOpenAssignments, + countCompletedIssuesWithLabel, + isRepoCollaborator, +}; From 073cc994cbf2eb5177f1e2088d03a5b775773e7e Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Thu, 16 Jul 2026 23:40:45 +0530 Subject: [PATCH 02/11] refactor: extract spam handling into shared helper Signed-off-by: Parv Ninama --- .../scripts/bot-issue-assign-on-comment.js | 31 +- .github/scripts/shared/core/issue-assign.js | 268 ++++++++++-------- .github/scripts/shared/helpers/spam.js | 96 +++++++ .github/scripts/shared/helpers/validation.js | 9 + 4 files changed, 261 insertions(+), 143 deletions(-) create mode 100644 .github/scripts/shared/helpers/spam.js diff --git a/.github/scripts/bot-issue-assign-on-comment.js b/.github/scripts/bot-issue-assign-on-comment.js index 9d795ae45..e397b57df 100644 --- a/.github/scripts/bot-issue-assign-on-comment.js +++ b/.github/scripts/bot-issue-assign-on-comment.js @@ -1,37 +1,12 @@ // .github/scripts/bot-issue-assign-on-comment.js // -// Replaces bot-beginner-assign-on-comment.js and bot-gfi_assign_on_comment.js. -// -// Triggered by the `issue_comment` (created) event. Figures out which -// difficulty level (if any) applies to the commented-on issue, then hands -// off to the shared engine with that level's config. All label-specific -// behavior lives in .github/scripts/configs/assignment-levels.js; this file -// is just wiring. -// -// Supporting a new difficulty level (e.g. "advanced") requires zero changes -// here — just add/enable its config in assignment-levels.js. +// Assigns user to a issue by commenting /assign -const { runAssignmentFlow, issueHasLabel } = require('./shared/issue-assign-core.js'); -const { LEVEL_CONFIGS } = require('./configs/assignment-levels.js'); +const { runAssignmentFlow } = require('./shared/core/issue-assign-core.js'); module.exports = async ({ github, context }) => { try { - const issue = context.payload.issue; - - if (!issue) { - console.log('[assign-bot] No issue in payload. Exiting.'); - return; - } - - const levelConfig = LEVEL_CONFIGS.find((config) => issueHasLabel(issue, config.label)); - - if (!levelConfig) { - console.log(`[assign-bot] Issue #${issue.number} has no recognized difficulty label. Exiting.`); - return; - } - - console.log(`[assign-bot] Issue #${issue.number} matched level "${levelConfig.label}".`); - await runAssignmentFlow({ github, context, levelConfig }); + await runAssignmentFlow({ github, context }); } catch (error) { console.error('[assign-bot] Unexpected error:', { message: error.message, diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index a93884bfb..4fc3e6097 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -1,58 +1,77 @@ // .github/scripts/shared/issue-assign-core.js // // Shared engine for "/assign"-style comment automation across every -// difficulty level (beginner, Good First Issue, intermediate, advanced, ...). +// difficulty level (Good First Issue, beginner, intermediate, advanced, ...). // -// This file intentionally contains NO per-label copy or behavior — that all -// lives in .github/scripts/configs/assignment-levels.js. This module only -// knows how to run the generic flow: +// All level/label data lives in config.js (CONFIG.repos[].labels and +// CONFIG.skillPrerequisites). This file has no per-label copy hardcoded +// except generic message templates built from `displayName` — so adding a +// new level, or pointing a repo at different label text, never requires +// touching this file. // +// Flow: // 1. Validate the webhook payload (real issue comment, not a bot, not a PR) -// 2. Confirm the issue carries the label this config cares about +// 2. Resolve which configured repo + skill level the issue belongs to // 3. If the comment is a plain "I'd like to help" message with no issue // assigned yet -> maybe post a one-time reminder to use `/assign` // 4. If the comment contains `/assign`: -// a. Enforce any prerequisite (e.g. "complete 1 GFI before taking a -// beginner issue") — post a one-time guard comment if unmet +// a. Enforce the prerequisite (completions of `requiredLevel`, +// counted against the HOME repo's label/history) — post a +// one-time guard comment if unmet // b. Refuse (with a comment) if the issue is already assigned // c. Enforce spam-list handling (hard block, or reduced limit) // d. Enforce the open-assignment cap // e. Assign the commenter -// f. Run any post-assignment chain (mentor assignment, CodeRabbit -// plan trigger, etc.) — each step is independent and non-fatal -// -// Adding a new difficulty level should never require touching this file. - -const fs = require('fs'); -function isSafeSearchToken(value) { - return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value); -} +const { CONFIG, LEVEL_KEYS } = require('../config.js'); +const { isSafeSearchToken } = require('../helpers/validation.js'); +const { + isSpamUser, + spamUsersBlocked, + isSpamLimited, + buildSpamBlockedMessage, + getAssignmentLimit, +} = require('../helpers/spam.js'); function commentRequestsAssignment(body) { return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); } -function issueHasLabel(issue, label) { - const target = label.toLowerCase(); - return Array.isArray(issue.labels) && issue.labels.some((l) => l.name?.toLowerCase() === target); -} - function getCurrentAssigneeMention(issue) { const login = issue?.assignees?.[0]?.login; return login ? `@${login}` : 'someone'; } -function isSpamUser(username, spamListPath) { - if (!spamListPath || !fs.existsSync(spamListPath)) return false; +function findRepoConfig(owner, repo) { + return CONFIG.repos.find((r) => r.owner === owner && r.repo === repo) || null; +} + +function findHomeRepoConfig() { + return CONFIG.repos.find((r) => r.isHome) || CONFIG.repos[0]; +} - const list = fs - .readFileSync(spamListPath, 'utf8') - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')); +/** + * Given an issue's labels and a repo config, returns the matching canonical + * level key (highest tier first, so an issue mistakenly double-labeled + * resolves to the more restrictive level) or null if none match. + */ +function resolveLevelKey(issue, repoConfig) { + const issueLabels = new Set((issue.labels || []).map((l) => l.name?.toLowerCase()).filter(Boolean)); + + for (let i = CONFIG.skillHierarchy.length - 1; i >= 0; i -= 1) { + const levelKey = CONFIG.skillHierarchy[i]; + const label = repoConfig.labels?.[levelKey]; + if (label && issueLabels.has(label.toLowerCase())) { + return levelKey; + } + } + return null; +} - return list.includes(username); +function unassignedIssuesUrl(owner, repo, label) { + return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent( + `is:issue is:open label:"${label}" no:assignee` + )}`; } async function getOpenAssignments({ github, owner, repo, username }) { @@ -67,10 +86,9 @@ async function getOpenAssignments({ github, owner, repo, username }) { } /** - * Counts closed issues carrying `label` that were assigned to `username`. - * Used to enforce prerequisites like "finish 1 GFI before taking a beginner - * issue". Returns null (rather than throwing) on unsafe input or API error - * so callers can choose to fail open. + * Counts closed issues carrying `label` (in the given repo) assigned to + * `username`. Returns null (rather than throwing) on unsafe input or API + * error so callers can choose to fail open. */ async function countCompletedIssuesWithLabel({ github, owner, repo, username, label }) { if (!isSafeSearchToken(owner) || !isSafeSearchToken(repo) || !isSafeSearchToken(username)) { @@ -126,8 +144,6 @@ async function isRepoCollaborator({ github, owner, repo, username }) { console.log('[assign-bot] isRepoCollaborator: not a collaborator', { username, status: error.status }); return false; } - // Unexpected error talking to the API — fail closed (don't treat as collaborator) - // but don't let it crash the whole run. console.error('[assign-bot] isRepoCollaborator: unexpected error', { username, message: error.message }); return false; } @@ -151,40 +167,54 @@ async function fetchAllComments({ github, owner, repo, issueNumber }) { }); } -/** - * Runs chained post-assignment steps in order. Each step is: - * { name: string, requiresPrevious?: boolean, run: async (ctx) => void } - * A step throwing is caught and logged; it never fails the overall run - * (the human assignment has already succeeded by this point). Steps with - * requiresPrevious=true are skipped if the prior step failed, mirroring the - * original "only trigger CodeRabbit if mentor assignment succeeded" logic. - */ -async function runPostAssignChain(chain, ctx) { - let previousSucceeded = true; - for (const step of chain || []) { - if (step.requiresPrevious && !previousSucceeded) { - console.log(`[assign-bot] Skipping chained step "${step.name}" (previous step failed).`); - continue; - } - try { - await step.run(ctx); - previousSucceeded = true; - console.log(`[assign-bot] Chained step "${step.name}" succeeded.`); - } catch (error) { - previousSucceeded = false; - console.error(`[assign-bot] Chained step "${step.name}" failed:`, { - message: error.message, - status: error.status, - }); - } - } +// --------------------------------------------------------------------------- +// Generic message templates, parameterized only by displayName/label/urls — +// this is what lets new levels avoid needing bespoke copy in config.js. +// --------------------------------------------------------------------------- + +function reminderMarkerFor(levelKey) { + return ``; } -/** - * Main entry point. `levelConfig` describes everything level-specific; - * see configs/assignment-levels.js for the shape and examples. - */ -async function runAssignmentFlow({ github, context, levelConfig }) { +function guardMarkerFor(levelKey) { + return ``; +} + +function buildReminderMessage(commenter) { + return `👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; +} + +function buildAlreadyAssignedMessage(commenter, issue, { owner, repo, label }) { + return `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to ${getCurrentAssigneeMention( + issue + )}, but we'd love your help on another one. You can find more "${label}" issues [here](${unassignedIssuesUrl( + owner, + repo, + label + )}).`; +} + +function buildGuardMessage(commenter, { owner, repo, prereqLabel, prereqDisplayName, requiredCount, currentDisplayName }) { + const timesPhrase = requiredCount === 1 ? 'one' : requiredCount; + const issuePhrase = requiredCount === 1 ? 'issue' : 'issues'; + return `👋 Hi @${commenter}! Thanks for your interest in contributing 💡\n\nBefore taking on a **${currentDisplayName}** issue, we ask contributors to complete at least ${timesPhrase} **${prereqDisplayName}** ${issuePhrase} first.\n\n👉 [Find one here](${unassignedIssuesUrl( + owner, + repo, + prereqLabel + )})\n\nOnce you've done that, come back — we'll be happy to assign this! 😊`; +} + +function buildLimitMessage(commenter, { maxAllowed, spamLimited }) { + return spamLimited + ? `Hi @${commenter}, you are limited to **${maxAllowed} active issue${maxAllowed === 1 ? '' : 's'}** at a time. Please complete your current assignment before requesting another.` + : `👋 Hi @${commenter}, you already have **${maxAllowed} open assignment${maxAllowed === 1 ? '' : 's'}**. Please finish one before requesting another.`; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +async function runAssignmentFlow({ github, context }) { const { payload } = context; const issue = payload.issue; const comment = payload.comment; @@ -205,18 +235,31 @@ async function runAssignmentFlow({ github, context, levelConfig }) { return; } - if (!issueHasLabel(issue, levelConfig.label)) { - console.log(`[assign-bot] Issue #${issue.number} does not have the "${levelConfig.label}" label. Exiting.`); + const owner = repo.owner.login; + const repoName = repo.name; + const repoConfig = findRepoConfig(owner, repoName); + + if (!repoConfig) { + console.log(`[assign-bot] ${owner}/${repoName} is not a configured repo. Exiting.`); return; } - const owner = repo.owner.login; - const repoName = repo.name; + const levelKey = resolveLevelKey(issue, repoConfig); + if (!levelKey) { + console.log(`[assign-bot] Issue #${issue.number} has no recognized skill label. Exiting.`); + return; + } + + const levelConfig = CONFIG.skillPrerequisites[levelKey]; + const label = repoConfig.labels[levelKey]; const commenter = comment.user.login; const issueNumber = issue.number; const isAssigned = Array.isArray(issue.assignees) && issue.assignees.length > 0; - // ---- Branch A: plain comment, no /assign — maybe post a reminder ---- + console.log(`[assign-bot] Issue #${issueNumber} in ${owner}/${repoName} matched level "${levelKey}".`); + + // ---- plain comment, no /assign — post a reminder ---- + if (!commentRequestsAssignment(comment.body)) { if (isAssigned) { console.log(`[assign-bot] Issue #${issueNumber} already assigned. Skipping reminder.`); @@ -236,36 +279,43 @@ async function runAssignmentFlow({ github, context, levelConfig }) { return; } - const reminderAlreadyPosted = comments.some((c) => c.body?.includes(levelConfig.reminderMarker)); - if (reminderAlreadyPosted) { + const marker = reminderMarkerFor(levelKey); + if (comments.some((c) => c.body?.includes(marker))) { console.log('[assign-bot] Reminder already posted. Skipping.'); return; } - const body = `${levelConfig.reminderMarker}\n${levelConfig.reminderMessageBuilder(commenter)}`; + const body = `${marker}\n${buildReminderMessage(commenter)}`; await postComment({ github, owner, repo: repoName, issueNumber, body }, 'assign reminder'); return; } - // ---- Branch B: /assign command ---- + // ---- /assign command ---- - // Prerequisite gate (e.g. "must complete 1 GFI before taking a beginner issue") - if (levelConfig.prerequisite) { - const { label: prereqLabel, count: requiredCount, guardMarker, guardMessageBuilder } = levelConfig.prerequisite; + // Prerequisite check, resolved against the HOME repo's history/labels. + if (levelConfig.requiredLevel && levelConfig.requiredCount > 0) { + const homeRepoConfig = findHomeRepoConfig(); + const prereqLabel = homeRepoConfig.labels[levelConfig.requiredLevel]; + const prereqDisplayName = CONFIG.skillPrerequisites[levelConfig.requiredLevel].displayName; const completedCount = await countCompletedIssuesWithLabel({ github, - owner, - repo: repoName, + owner: homeRepoConfig.owner, + repo: homeRepoConfig.repo, username: commenter, label: prereqLabel, }); - console.log('[assign-bot] Prerequisite check:', { commenter, prereqLabel, requiredCount, completedCount }); + console.log('[assign-bot] Prerequisite check:', { + commenter, + prereqLabel, + requiredCount: levelConfig.requiredCount, + completedCount, + }); if (completedCount === null) { console.log('[assign-bot] Skipping prerequisite guard due to API error (fail open).'); - } else if (completedCount < requiredCount) { + } else if (completedCount < levelConfig.requiredCount) { let comments; try { comments = await fetchAllComments({ github, owner, repo: repoName, issueNumber }); @@ -274,9 +324,16 @@ async function runAssignmentFlow({ github, context, levelConfig }) { return; } - const guardAlreadyPosted = comments.some((c) => c.body?.includes(guardMarker)); - if (!guardAlreadyPosted) { - const body = `${guardMarker}\n${guardMessageBuilder(commenter, { owner, repo: repoName })}`; + const marker = guardMarkerFor(levelKey); + if (!comments.some((c) => c.body?.includes(marker))) { + const body = `${marker}\n${buildGuardMessage(commenter, { + owner, + repo: repoName, + prereqLabel, + prereqDisplayName, + requiredCount: levelConfig.requiredCount, + currentDisplayName: levelConfig.displayName, + })}`; await postComment({ github, owner, repo: repoName, issueNumber, body }, 'prerequisite guard'); } return; @@ -285,31 +342,30 @@ async function runAssignmentFlow({ github, context, levelConfig }) { // Already assigned? if (isAssigned) { - const body = levelConfig.alreadyAssignedMessageBuilder(commenter, issue, { owner, repo: repoName }); + const body = buildAlreadyAssignedMessage(commenter, issue, { owner, repo: repoName, label }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'already-assigned notice'); return; } // Spam handling - const spamConfig = levelConfig.spam; - const spamUser = spamConfig ? isSpamUser(commenter, spamConfig.listPath) : false; + const spamUser = isSpamUser(commenter); - if (spamUser && spamConfig.behavior === 'block') { - console.log(`[assign-bot] Spam user @${commenter} blocked from "${levelConfig.label}" issues.`); - const body = spamConfig.blockedMessageBuilder(commenter); + if (spamUser && spamUsersBlocked(levelKey)) { + console.log(`[assign-bot] Spam user @${commenter} blocked from "${levelKey}" issues.`); + const gfiDisplayName = CONFIG.skillPrerequisites[LEVEL_KEYS.GFI].displayName; + const body = buildSpamBlockedMessage(commenter, { prereqDisplayName: gfiDisplayName }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'spam restriction notice'); return; } - // Open-assignment cap (spam users may get a reduced cap instead of a hard block) - const maxAllowed = - spamUser && spamConfig?.behavior === 'limit' ? spamConfig.limitTo : levelConfig.maxOpenAssignments; + const maxAllowed = getAssignmentLimit(levelKey, spamUser); const openCount = await getOpenAssignments({ github, owner, repo: repoName, username: commenter }); console.log('[assign-bot] Limit check:', { commenter, openCount, spamUser, maxAllowed }); if (openCount >= maxAllowed) { - const body = levelConfig.limitMessageBuilder(commenter, { maxAllowed, spamLimited: spamUser && spamConfig?.behavior === 'limit' }); + const spamLimited = isSpamLimited(levelKey, spamUser); + const body = buildLimitMessage(commenter, { maxAllowed, spamLimited }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'limit warning'); return; } @@ -320,29 +376,11 @@ async function runAssignmentFlow({ github, context, levelConfig }) { console.log(`[assign-bot] Assigned #${issueNumber} to @${commenter}.`); } catch (error) { console.error('[assign-bot] Failed to assign issue:', { message: error.message }); - return; } + return; - // Post-assignment chain (mentor assignment, CodeRabbit plan, etc.) - await runPostAssignChain(levelConfig.postAssignChain, { - github, - context, - owner, - repo: repoName, - issue, - issueNumber, - assignee: { login: commenter, type: 'User' }, - }); } module.exports = { runAssignmentFlow, - isSafeSearchToken, - commentRequestsAssignment, - issueHasLabel, - getCurrentAssigneeMention, - isSpamUser, - getOpenAssignments, - countCompletedIssuesWithLabel, - isRepoCollaborator, }; diff --git a/.github/scripts/shared/helpers/spam.js b/.github/scripts/shared/helpers/spam.js new file mode 100644 index 000000000..25e48de67 --- /dev/null +++ b/.github/scripts/shared/helpers/spam.js @@ -0,0 +1,96 @@ +// --------------------------------------------------------------------------- +// Spam helpers +// --------------------------------------------------------------------------- + +const fs = require("fs"); +const { CONFIG, LEVEL_KEYS } = require("../config"); + +/** + * Returns true if the contributor appears in the spam list. + * + * The spam list is maintained as one username per line. + * Blank lines and comments beginning with "#" are ignored. + * + * @param {string} username + * @returns {boolean} + */ +function isSpamUser(username) { + if (!fs.existsSync(CONFIG.spamListPath)) { + return false; + } + + const users = fs + .readFileSync(CONFIG.spamListPath, "utf8") + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + + return users.includes(username); +} + +/** + * Returns true if spam-listed users are completely blocked + * from requesting assignments at this difficulty. + * + * @param {string} levelKey + * @returns {boolean} + */ +function spamUsersBlocked(levelKey) { + return ( + levelKey === LEVEL_KEYS.BEGINNER || + levelKey === LEVEL_KEYS.ADVANCED + ); +} + +/** + * Returns the effective assignment limit for a contributor. + * + * Spam-listed contributors may receive a stricter limit than + * the normal limit configured for the skill level. + * + * @param {string} levelKey + * @param {boolean} spamUser + * @param {number} defaultLimit + * @returns {number} + */ +function getAssignmentLimit(levelKey, spamUser) { + const defaultLimit = + levelKey === LEVEL_KEYS.ADVANCED ? 1 : 2; + + if (!spamUser) { + return defaultLimit; + } + + if ( + levelKey === LEVEL_KEYS.GFI || + levelKey === LEVEL_KEYS.INTERMEDIATE + ) { + return 1; + } + + return defaultLimit; +} + +function isSpamLimited(levelKey, spamUser) { + return ( + spamUser && + ( + levelKey === LEVEL_KEYS.GFI || + levelKey === LEVEL_KEYS.INTERMEDIATE + ) + ); +} + +function buildSpamBlockedMessage(commenter, { prereqDisplayName }) { + return `Hi @${commenter}, your account is currently restricted to **${prereqDisplayName}** issues. Please complete one or contact a maintainer to have restrictions reviewed.`; +} + + +module.exports = { + isSpamUser, + spamUsersBlocked, + spamAssignmentLimit, + getAssignmentLimit, + isSpamLimited, + buildSpamBlockedMessage, +}; diff --git a/.github/scripts/shared/helpers/validation.js b/.github/scripts/shared/helpers/validation.js index 9330b37a7..669d21b08 100644 --- a/.github/scripts/shared/helpers/validation.js +++ b/.github/scripts/shared/helpers/validation.js @@ -15,6 +15,15 @@ function isSafeLabel(label) { && !/[\\"]/u.test(label); } +/** + * Stricter: used for owner/repo/username, which GitHub itself restricts to + * a known charset. + */ +function isSafeSearchToken(value) { + return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value); +} + module.exports = { isSafeLabel, + isSafeSearchToken, }; From 2f58d49a6a0a7bd71d372ca7069734987f0c1c08 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Sat, 18 Jul 2026 01:42:37 +0530 Subject: [PATCH 03/11] refactor: extract GITHUB API operatinos into a shared helper Signed-off-by: Parv Ninama --- .github/scripts/shared/api/github-api.js | 115 ++++++++++++++++++++ .github/scripts/shared/core/issue-assign.js | 115 +++----------------- 2 files changed, 133 insertions(+), 97 deletions(-) diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index 96774f4e6..105edf430 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -3,6 +3,7 @@ // --------------------------------------------------------------------------- const { CONFIG } = require('../config'); +const { isSafeSearchToken } = require("./validation"); /** * Counts closed issues historically assigned to a contributor at a given label, @@ -64,7 +65,121 @@ async function fetchIssuesBatch(github, repoConfig) { } } +async function getOpenAssignments({ github, owner, repo, username }) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + assignee: username, + state: 'open', + per_page: 100, + }); + return issues.length; +} + +/** + * Counts closed issues carrying `label` (in the given repo) assigned to + * `username`. Returns null (rather than throwing) on unsafe input or API + * error so callers can choose to fail open. + */ +async function countCompletedIssuesWithLabel({ github, owner, repo, username, label }) { + if (!isSafeSearchToken(owner) || !isSafeSearchToken(repo) || !isSafeSearchToken(username)) { + return null; + } + + const searchQuery = [ + `repo:${owner}/${repo}`, + `label:"${label}"`, + 'is:issue', + 'is:closed', + `assignee:${username}`, + ].join(' '); + + try { + const result = await github.graphql( + ` + query ($searchQuery: String!) { + search(type: ISSUE, query: $searchQuery) { + issueCount + } + } + `, + { searchQuery } + ); + return result?.search?.issueCount ?? 0; + } catch (error) { + console.error('[github-api] countCompletedIssuesWithLabel failed:', { + owner, + repo, + username, + label, + message: error.message, + }); + return null; + } +} + +async function isRepoCollaborator({ github, owner, repo, username }) { + if (username === owner) { + console.log(`[github-api] @${username} is the repo owner — treated as collaborator.`); + return true; + } + + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + const permission = response?.data?.permission; + const isTeamMember = ['admin', 'write', 'maintain', 'read'].includes(permission); + console.log('[github-api] isRepoCollaborator:', { username, permission, isTeamMember }); + return isTeamMember; + } catch (error) { + if (error?.status === 401 || error?.status === 403 || error?.status === 404) { + console.log('[github-api] isRepoCollaborator: not a collaborator', { username, status: error.status }); + return false; + } + console.error('[github-api] isRepoCollaborator: unexpected error', { username, message: error.message }); + return false; + } +} + +async function postComment({ github, owner, repo, issueNumber, body }, logLabel) { + try { + await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); + console.log(`[github-api] Posted comment: ${logLabel}`); + } catch (error) { + console.error(`[github-api] Failed to post comment (${logLabel}):`, { message: error.message }); + } +} + +async function fetchAllComments({ github, owner, repo, issueNumber }) { + return github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); +} + +async function assignIssue({ + github, + owner, + repo, + issueNumber, + username, +}) { + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issueNumber, + assignees: [username], + }); +} + module.exports = { fetchIssuesBatch, countClosedIssuesByAssignee, + getOpenAssignments, + countCompletedIssuesWithLabel, + isRepoCollaborator, + postComment, + fetchAllComments, + assignIssue, }; diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index 4fc3e6097..c07617ea8 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -24,7 +24,6 @@ // e. Assign the commenter const { CONFIG, LEVEL_KEYS } = require('../config.js'); -const { isSafeSearchToken } = require('../helpers/validation.js'); const { isSpamUser, spamUsersBlocked, @@ -32,6 +31,14 @@ const { buildSpamBlockedMessage, getAssignmentLimit, } = require('../helpers/spam.js'); +const{ + getOpenAssignments, + countCompletedIssuesWithLabel, + isRepoCollaborator, + postComment, + fetchAllComments, + assignIssue, +} = require('../api/github-api.js') function commentRequestsAssignment(body) { return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); @@ -74,99 +81,6 @@ function unassignedIssuesUrl(owner, repo, label) { )}`; } -async function getOpenAssignments({ github, owner, repo, username }) { - const issues = await github.paginate(github.rest.issues.listForRepo, { - owner, - repo, - assignee: username, - state: 'open', - per_page: 100, - }); - return issues.length; -} - -/** - * Counts closed issues carrying `label` (in the given repo) assigned to - * `username`. Returns null (rather than throwing) on unsafe input or API - * error so callers can choose to fail open. - */ -async function countCompletedIssuesWithLabel({ github, owner, repo, username, label }) { - if (!isSafeSearchToken(owner) || !isSafeSearchToken(repo) || !isSafeSearchToken(username)) { - return null; - } - - const searchQuery = [ - `repo:${owner}/${repo}`, - `label:"${label}"`, - 'is:issue', - 'is:closed', - `assignee:${username}`, - ].join(' '); - - try { - const result = await github.graphql( - ` - query ($searchQuery: String!) { - search(type: ISSUE, query: $searchQuery) { - issueCount - } - } - `, - { searchQuery } - ); - return result?.search?.issueCount ?? 0; - } catch (error) { - console.error('[assign-bot] countCompletedIssuesWithLabel failed:', { - owner, - repo, - username, - label, - message: error.message, - }); - return null; - } -} - -async function isRepoCollaborator({ github, owner, repo, username }) { - if (username === owner) { - console.log(`[assign-bot] @${username} is the repo owner — treated as collaborator.`); - return true; - } - - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); - const permission = response?.data?.permission; - const isTeamMember = ['admin', 'write', 'maintain', 'read'].includes(permission); - console.log('[assign-bot] isRepoCollaborator:', { username, permission, isTeamMember }); - return isTeamMember; - } catch (error) { - if (error?.status === 401 || error?.status === 403 || error?.status === 404) { - console.log('[assign-bot] isRepoCollaborator: not a collaborator', { username, status: error.status }); - return false; - } - console.error('[assign-bot] isRepoCollaborator: unexpected error', { username, message: error.message }); - return false; - } -} - -async function postComment({ github, owner, repo, issueNumber, body }, logLabel) { - try { - await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); - console.log(`[assign-bot] Posted comment: ${logLabel}`); - } catch (error) { - console.error(`[assign-bot] Failed to post comment (${logLabel}):`, { message: error.message }); - } -} - -async function fetchAllComments({ github, owner, repo, issueNumber }) { - return github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: issueNumber, - per_page: 100, - }); -} - // --------------------------------------------------------------------------- // Generic message templates, parameterized only by displayName/label/urls — // this is what lets new levels avoid needing bespoke copy in config.js. @@ -372,10 +286,17 @@ async function runAssignmentFlow({ github, context }) { // Assign try { - await github.rest.issues.addAssignees({ owner, repo: repoName, issue_number: issueNumber, assignees: [commenter] }); - console.log(`[assign-bot] Assigned #${issueNumber} to @${commenter}.`); + await assignIssue({ + github, + owner, + repo: repoName, + issueNumber, + username: commenter, + }); } catch (error) { - console.error('[assign-bot] Failed to assign issue:', { message: error.message }); + console.error("[assign-bot] Failed to assign issue:", { + message: error.message, + }); } return; From 5f38f39d107c2f70a50e766ee795b35e1480b52e Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Sat, 18 Jul 2026 01:58:33 +0530 Subject: [PATCH 04/11] refactor: extract message builders into shared helper Signed-off-by: Parv Ninama --- .github/scripts/shared/core/issue-assign.js | 66 ++++++--------------- .github/scripts/shared/helpers/message.js | 56 +++++++++++++++++ .github/scripts/shared/helpers/spam.js | 6 -- 3 files changed, 74 insertions(+), 54 deletions(-) create mode 100644 .github/scripts/shared/helpers/message.js diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index c07617ea8..d073dc82e 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -24,30 +24,36 @@ // e. Assign the commenter const { CONFIG, LEVEL_KEYS } = require('../config.js'); + const { - isSpamUser, - spamUsersBlocked, - isSpamLimited, - buildSpamBlockedMessage, - getAssignmentLimit, -} = require('../helpers/spam.js'); -const{ getOpenAssignments, countCompletedIssuesWithLabel, isRepoCollaborator, postComment, fetchAllComments, assignIssue, -} = require('../api/github-api.js') +} = require('../api/github-api.js'); + +const { + buildAlreadyAssignedMessage, + buildGuardMessage, + buildLimitMessage, + buildReminderMessage, + buildSpamBlockedMessage, +} = require('../helpers/message.js'); + +const { + isSpamUser, + spamUsersBlocked, + isSpamLimited, + getAssignmentLimit, +} = require('../helpers/spam.js'); function commentRequestsAssignment(body) { return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); } -function getCurrentAssigneeMention(issue) { - const login = issue?.assignees?.[0]?.login; - return login ? `@${login}` : 'someone'; -} + function findRepoConfig(owner, repo) { return CONFIG.repos.find((r) => r.owner === owner && r.repo === repo) || null; @@ -75,12 +81,6 @@ function resolveLevelKey(issue, repoConfig) { return null; } -function unassignedIssuesUrl(owner, repo, label) { - return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent( - `is:issue is:open label:"${label}" no:assignee` - )}`; -} - // --------------------------------------------------------------------------- // Generic message templates, parameterized only by displayName/label/urls — // this is what lets new levels avoid needing bespoke copy in config.js. @@ -94,36 +94,6 @@ function guardMarkerFor(levelKey) { return ``; } -function buildReminderMessage(commenter) { - return `👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; -} - -function buildAlreadyAssignedMessage(commenter, issue, { owner, repo, label }) { - return `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to ${getCurrentAssigneeMention( - issue - )}, but we'd love your help on another one. You can find more "${label}" issues [here](${unassignedIssuesUrl( - owner, - repo, - label - )}).`; -} - -function buildGuardMessage(commenter, { owner, repo, prereqLabel, prereqDisplayName, requiredCount, currentDisplayName }) { - const timesPhrase = requiredCount === 1 ? 'one' : requiredCount; - const issuePhrase = requiredCount === 1 ? 'issue' : 'issues'; - return `👋 Hi @${commenter}! Thanks for your interest in contributing 💡\n\nBefore taking on a **${currentDisplayName}** issue, we ask contributors to complete at least ${timesPhrase} **${prereqDisplayName}** ${issuePhrase} first.\n\n👉 [Find one here](${unassignedIssuesUrl( - owner, - repo, - prereqLabel - )})\n\nOnce you've done that, come back — we'll be happy to assign this! 😊`; -} - -function buildLimitMessage(commenter, { maxAllowed, spamLimited }) { - return spamLimited - ? `Hi @${commenter}, you are limited to **${maxAllowed} active issue${maxAllowed === 1 ? '' : 's'}** at a time. Please complete your current assignment before requesting another.` - : `👋 Hi @${commenter}, you already have **${maxAllowed} open assignment${maxAllowed === 1 ? '' : 's'}**. Please finish one before requesting another.`; -} - // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- diff --git a/.github/scripts/shared/helpers/message.js b/.github/scripts/shared/helpers/message.js new file mode 100644 index 000000000..19d008c92 --- /dev/null +++ b/.github/scripts/shared/helpers/message.js @@ -0,0 +1,56 @@ +// --------------------------------------------------------------------------- +// Message Builders +// --------------------------------------------------------------------------- + +function buildReminderMessage(commenter) { + return `👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; +} + +function getCurrentAssigneeMention(issue) { + const login = issue?.assignees?.[0]?.login; + return login ? `@${login}` : 'someone'; +} + +function unassignedIssuesUrl(owner, repo, label) { + return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent( + `is:issue is:open label:"${label}" no:assignee` + )}`; +} + +function buildAlreadyAssignedMessage(commenter, issue, { owner, repo, label }) { + return `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to ${getCurrentAssigneeMention( + issue + )}, but we'd love your help on another one. You can find more "${label}" issues [here](${unassignedIssuesUrl( + owner, + repo, + label + )}).`; +} + +function buildGuardMessage(commenter, { owner, repo, prereqLabel, prereqDisplayName, requiredCount, currentDisplayName }) { + const timesPhrase = requiredCount === 1 ? 'one' : requiredCount; + const issuePhrase = requiredCount === 1 ? 'issue' : 'issues'; + return `👋 Hi @${commenter}! Thanks for your interest in contributing 💡\n\nBefore taking on a **${currentDisplayName}** issue, we ask contributors to complete at least ${timesPhrase} **${prereqDisplayName}** ${issuePhrase} first.\n\n👉 [Find one here](${unassignedIssuesUrl( + owner, + repo, + prereqLabel + )})\n\nOnce you've done that, come back — we'll be happy to assign this! 😊`; +} + +function buildLimitMessage(commenter, { maxAllowed, spamLimited }) { + return spamLimited + ? `Hi @${commenter}, you are limited to **${maxAllowed} active issue${maxAllowed === 1 ? '' : 's'}** at a time. Please complete your current assignment before requesting another.` + : `👋 Hi @${commenter}, you already have **${maxAllowed} open assignment${maxAllowed === 1 ? '' : 's'}**. Please finish one before requesting another.`; +} + +function buildSpamBlockedMessage(commenter, { prereqDisplayName }) { + return `Hi @${commenter}, your account is currently restricted to **${prereqDisplayName}** issues. Please complete one or contact a maintainer to have restrictions reviewed.`; +} + +module.exports = { + buildAlreadyAssignedMessage, + buildGuardMessage, + buildLimitMessage, + buildReminderMessage, + buildSpamBlockedMessage +}; diff --git a/.github/scripts/shared/helpers/spam.js b/.github/scripts/shared/helpers/spam.js index 25e48de67..01cd0b168 100644 --- a/.github/scripts/shared/helpers/spam.js +++ b/.github/scripts/shared/helpers/spam.js @@ -81,16 +81,10 @@ function isSpamLimited(levelKey, spamUser) { ); } -function buildSpamBlockedMessage(commenter, { prereqDisplayName }) { - return `Hi @${commenter}, your account is currently restricted to **${prereqDisplayName}** issues. Please complete one or contact a maintainer to have restrictions reviewed.`; -} - - module.exports = { isSpamUser, spamUsersBlocked, spamAssignmentLimit, getAssignmentLimit, isSpamLimited, - buildSpamBlockedMessage, }; From 8c801b32e37e2fa09dfe79d89d1a6c8dc0bf943a Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:12:29 +0530 Subject: [PATCH 05/11] refactor: simplify assignment workflow structure Signed-off-by: Parv Ninama --- .github/scripts/shared/core/issue-assign.js | 39 +++++-------- .github/scripts/shared/helpers/comment.js | 61 +++++++++++++++++++++ .github/scripts/shared/helpers/message.js | 56 ------------------- .github/scripts/shared/helpers/spam.js | 2 - 4 files changed, 74 insertions(+), 84 deletions(-) delete mode 100644 .github/scripts/shared/helpers/message.js diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index d073dc82e..8a671e742 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -35,12 +35,14 @@ const { } = require('../api/github-api.js'); const { - buildAlreadyAssignedMessage, - buildGuardMessage, - buildLimitMessage, - buildReminderMessage, - buildSpamBlockedMessage, -} = require('../helpers/message.js'); + buildAlreadyAssignedComment, + buildGuardComment, + buildLimitComment, + buildReminderComment, + buildSpamBlockedComment, + reminderMarkerFor, + guardMarkerFor, +} = require('../helpers/comment.js'); const { isSpamUser, @@ -53,8 +55,6 @@ function commentRequestsAssignment(body) { return typeof body === 'string' && /(^|\s)\/assign(\s|$)/i.test(body); } - - function findRepoConfig(owner, repo) { return CONFIG.repos.find((r) => r.owner === owner && r.repo === repo) || null; } @@ -81,19 +81,6 @@ function resolveLevelKey(issue, repoConfig) { return null; } -// --------------------------------------------------------------------------- -// Generic message templates, parameterized only by displayName/label/urls — -// this is what lets new levels avoid needing bespoke copy in config.js. -// --------------------------------------------------------------------------- - -function reminderMarkerFor(levelKey) { - return ``; -} - -function guardMarkerFor(levelKey) { - return ``; -} - // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -169,7 +156,7 @@ async function runAssignmentFlow({ github, context }) { return; } - const body = `${marker}\n${buildReminderMessage(commenter)}`; + const body = `${marker}\n${buildReminderComment(commenter)}`; await postComment({ github, owner, repo: repoName, issueNumber, body }, 'assign reminder'); return; } @@ -210,7 +197,7 @@ async function runAssignmentFlow({ github, context }) { const marker = guardMarkerFor(levelKey); if (!comments.some((c) => c.body?.includes(marker))) { - const body = `${marker}\n${buildGuardMessage(commenter, { + const body = `${marker}\n${buildGuardComment(commenter, { owner, repo: repoName, prereqLabel, @@ -226,7 +213,7 @@ async function runAssignmentFlow({ github, context }) { // Already assigned? if (isAssigned) { - const body = buildAlreadyAssignedMessage(commenter, issue, { owner, repo: repoName, label }); + const body = buildAlreadyAssignedComment(commenter, issue, { owner, repo: repoName, label }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'already-assigned notice'); return; } @@ -237,7 +224,7 @@ async function runAssignmentFlow({ github, context }) { if (spamUser && spamUsersBlocked(levelKey)) { console.log(`[assign-bot] Spam user @${commenter} blocked from "${levelKey}" issues.`); const gfiDisplayName = CONFIG.skillPrerequisites[LEVEL_KEYS.GFI].displayName; - const body = buildSpamBlockedMessage(commenter, { prereqDisplayName: gfiDisplayName }); + const body = buildSpamBlockedComment(commenter, { prereqDisplayName: gfiDisplayName }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'spam restriction notice'); return; } @@ -249,7 +236,7 @@ async function runAssignmentFlow({ github, context }) { if (openCount >= maxAllowed) { const spamLimited = isSpamLimited(levelKey, spamUser); - const body = buildLimitMessage(commenter, { maxAllowed, spamLimited }); + const body = buildLimitComment(commenter, { maxAllowed, spamLimited }); await postComment({ github, owner, repo: repoName, issueNumber, body }, 'limit warning'); return; } diff --git a/.github/scripts/shared/helpers/comment.js b/.github/scripts/shared/helpers/comment.js index e6456ea6a..d80513230 100644 --- a/.github/scripts/shared/helpers/comment.js +++ b/.github/scripts/shared/helpers/comment.js @@ -104,8 +104,69 @@ function buildRecommendationComment(username, issues, completedDisplayName, unlo ].join('\n'); } +function buildReminderComment(commenter) { + return `👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; +} + +function getCurrentAssigneeMention(issue) { + const login = issue?.assignees?.[0]?.login; + return login ? `@${login}` : 'someone'; +} + +function unassignedIssuesUrl(owner, repo, label) { + return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent( + `is:issue is:open label:"${label}" no:assignee` + )}`; +} + +function buildAlreadyAssignedComment(commenter, issue, { owner, repo, label }) { + return `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to ${getCurrentAssigneeMention( + issue + )}, but we'd love your help on another one. You can find more "${label}" issues [here](${unassignedIssuesUrl( + owner, + repo, + label + )}).`; +} + +function buildGuardComment(commenter, { owner, repo, prereqLabel, prereqDisplayName, requiredCount, currentDisplayName }) { + const timesPhrase = requiredCount === 1 ? 'one' : requiredCount; + const issuePhrase = requiredCount === 1 ? 'issue' : 'issues'; + return `👋 Hi @${commenter}! Thanks for your interest in contributing 💡\n\nBefore taking on a **${currentDisplayName}** issue, we ask contributors to complete at least ${timesPhrase} **${prereqDisplayName}** ${issuePhrase} first.\n\n👉 [Find one here](${unassignedIssuesUrl( + owner, + repo, + prereqLabel + )})\n\nOnce you've done that, come back — we'll be happy to assign this! 😊`; +} + +function buildLimitComment(commenter, { maxAllowed, spamLimited }) { + return spamLimited + ? `Hi @${commenter}, you are limited to **${maxAllowed} active issue${maxAllowed === 1 ? '' : 's'}** at a time. Please complete your current assignment before requesting another.` + : `👋 Hi @${commenter}, you already have **${maxAllowed} open assignment${maxAllowed === 1 ? '' : 's'}**. Please finish one before requesting another.`; +} + +function buildSpamBlockedComment(commenter, { prereqDisplayName }) { + return `Hi @${commenter}, your account is currently restricted to **${prereqDisplayName}** issues. Please complete one or contact a maintainer to have restrictions reviewed.`; +} + +function reminderMarkerFor(levelKey) { + return ``; +} + +function guardMarkerFor(levelKey) { + return ``; +} + + module.exports = { buildIssueListBlock, buildMilestoneBlock, buildRecommendationComment, + buildAlreadyAssignedComment, + buildGuardComment, + buildLimitComment, + buildReminderComment, + buildSpamBlockedComment, + reminderMarkerFor, + guardMarkerFor, }; diff --git a/.github/scripts/shared/helpers/message.js b/.github/scripts/shared/helpers/message.js deleted file mode 100644 index 19d008c92..000000000 --- a/.github/scripts/shared/helpers/message.js +++ /dev/null @@ -1,56 +0,0 @@ -// --------------------------------------------------------------------------- -// Message Builders -// --------------------------------------------------------------------------- - -function buildReminderMessage(commenter) { - return `👋 Hi @${commenter}! If you'd like to work on this issue, please comment \`/assign\` to get assigned.`; -} - -function getCurrentAssigneeMention(issue) { - const login = issue?.assignees?.[0]?.login; - return login ? `@${login}` : 'someone'; -} - -function unassignedIssuesUrl(owner, repo, label) { - return `https://github.com/${owner}/${repo}/issues?q=${encodeURIComponent( - `is:issue is:open label:"${label}" no:assignee` - )}`; -} - -function buildAlreadyAssignedMessage(commenter, issue, { owner, repo, label }) { - return `👋 Hi @${commenter}, thanks for your interest! This issue is already assigned to ${getCurrentAssigneeMention( - issue - )}, but we'd love your help on another one. You can find more "${label}" issues [here](${unassignedIssuesUrl( - owner, - repo, - label - )}).`; -} - -function buildGuardMessage(commenter, { owner, repo, prereqLabel, prereqDisplayName, requiredCount, currentDisplayName }) { - const timesPhrase = requiredCount === 1 ? 'one' : requiredCount; - const issuePhrase = requiredCount === 1 ? 'issue' : 'issues'; - return `👋 Hi @${commenter}! Thanks for your interest in contributing 💡\n\nBefore taking on a **${currentDisplayName}** issue, we ask contributors to complete at least ${timesPhrase} **${prereqDisplayName}** ${issuePhrase} first.\n\n👉 [Find one here](${unassignedIssuesUrl( - owner, - repo, - prereqLabel - )})\n\nOnce you've done that, come back — we'll be happy to assign this! 😊`; -} - -function buildLimitMessage(commenter, { maxAllowed, spamLimited }) { - return spamLimited - ? `Hi @${commenter}, you are limited to **${maxAllowed} active issue${maxAllowed === 1 ? '' : 's'}** at a time. Please complete your current assignment before requesting another.` - : `👋 Hi @${commenter}, you already have **${maxAllowed} open assignment${maxAllowed === 1 ? '' : 's'}**. Please finish one before requesting another.`; -} - -function buildSpamBlockedMessage(commenter, { prereqDisplayName }) { - return `Hi @${commenter}, your account is currently restricted to **${prereqDisplayName}** issues. Please complete one or contact a maintainer to have restrictions reviewed.`; -} - -module.exports = { - buildAlreadyAssignedMessage, - buildGuardMessage, - buildLimitMessage, - buildReminderMessage, - buildSpamBlockedMessage -}; diff --git a/.github/scripts/shared/helpers/spam.js b/.github/scripts/shared/helpers/spam.js index 01cd0b168..9b199caf2 100644 --- a/.github/scripts/shared/helpers/spam.js +++ b/.github/scripts/shared/helpers/spam.js @@ -50,7 +50,6 @@ function spamUsersBlocked(levelKey) { * * @param {string} levelKey * @param {boolean} spamUser - * @param {number} defaultLimit * @returns {number} */ function getAssignmentLimit(levelKey, spamUser) { @@ -84,7 +83,6 @@ function isSpamLimited(levelKey, spamUser) { module.exports = { isSpamUser, spamUsersBlocked, - spamAssignmentLimit, getAssignmentLimit, isSpamLimited, }; From 7a5b898b123b1b60bc682f55023350b5fa5c9682 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:25:21 +0530 Subject: [PATCH 06/11] feat: add issue comment workflow for assignment bot Signed-off-by: Parv Ninama --- .../bot-issue-assign-on-comment.yaml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/bot-issue-assign-on-comment.yaml diff --git a/.github/workflows/bot-issue-assign-on-comment.yaml b/.github/workflows/bot-issue-assign-on-comment.yaml new file mode 100644 index 000000000..9b727afa9 --- /dev/null +++ b/.github/workflows/bot-issue-assign-on-comment.yaml @@ -0,0 +1,39 @@ +name: Issue Assignment Bot + +on: + issue_comment: + types: [created] + +permissions: + issues: write + pull-requests: read + contents: read + +concurrency: + group: assign-issue-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + assign-issue: + runs-on: hl-sdk-py-lin-md + if: github.event.issue.pull_request == null + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout trusted base repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + + - name: Assign issue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/scripts/bot-issue-assign-on-comment.js'); + await script({ github, context, core }); From 6e1a609f244ca5ea7a926ef716fba41d81ea5d51 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:35:08 +0530 Subject: [PATCH 07/11] docs: improve assignment workflow documentation Signed-off-by: Parv Ninama --- .github/scripts/shared/core/issue-assign.js | 39 +++++++++++---------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index 8a671e742..ae8c9c1ea 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -1,27 +1,28 @@ // .github/scripts/shared/issue-assign-core.js // -// Shared engine for "/assign"-style comment automation across every -// difficulty level (Good First Issue, beginner, intermediate, advanced, ...). +// Shared engine for issue-comment assignment automation. // -// All level/label data lives in config.js (CONFIG.repos[].labels and -// CONFIG.skillPrerequisites). This file has no per-label copy hardcoded -// except generic message templates built from `displayName` — so adding a -// new level, or pointing a repo at different label text, never requires -// touching this file. +// All repository-specific labels, skill levels, and prerequisites are +// configured in config.js. +// +// Handles: +// • one-time reminders for contributors who express interest +// • "/assign" requests +// • prerequisite enforcement +// • spam restrictions +// • assignment limits +// • issue assignment // // Flow: -// 1. Validate the webhook payload (real issue comment, not a bot, not a PR) -// 2. Resolve which configured repo + skill level the issue belongs to -// 3. If the comment is a plain "I'd like to help" message with no issue -// assigned yet -> maybe post a one-time reminder to use `/assign` -// 4. If the comment contains `/assign`: -// a. Enforce the prerequisite (completions of `requiredLevel`, -// counted against the HOME repo's label/history) — post a -// one-time guard comment if unmet -// b. Refuse (with a comment) if the issue is already assigned -// c. Enforce spam-list handling (hard block, or reduced limit) -// d. Enforce the open-assignment cap -// e. Assign the commenter +// 1. Validate the issue comment event +// 2. Resolve the configured repository and skill level +// 3. If the comment is not "/assign", optionally post a one-time reminder +// 4. Otherwise: +// • enforce prerequisites +// • reject already-assigned issues +// • apply spam restrictions +// • enforce assignment limits +// • assign the issue const { CONFIG, LEVEL_KEYS } = require('../config.js'); From 764d010359e9a38b996855bb948a2a312b37b255 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:45:41 +0530 Subject: [PATCH 08/11] refactor: remove intermediate assignment guard Signed-off-by: Parv Ninama --- .../scripts/bot-intermediate-assignment.js | 243 ------------------ .../workflows/bot-intermediate-assignment.yml | 49 ---- 2 files changed, 292 deletions(-) delete mode 100644 .github/scripts/bot-intermediate-assignment.js delete mode 100644 .github/workflows/bot-intermediate-assignment.yml diff --git a/.github/scripts/bot-intermediate-assignment.js b/.github/scripts/bot-intermediate-assignment.js deleted file mode 100644 index 5e2a68c76..000000000 --- a/.github/scripts/bot-intermediate-assignment.js +++ /dev/null @@ -1,243 +0,0 @@ -const shared = require('./shared/labels.js'); -const COMMENT_MARKER = process.env.INTERMEDIATE_COMMENT_MARKER || ''; -const INTERMEDIATE_LABEL = process.env.INTERMEDIATE_LABEL?.trim() || shared.INTERMEDIATE_LABEL; -const BEGINNER_LABEL = process.env.BEGINNER_LABEL?.trim() || shared.BEGINNER_LABEL; -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 || ''); -const REQUIRED_BEGINNER_ISSUE_COUNT = 1 ; - -function isSafeSearchToken(value) { - return typeof value === 'string' && /^[a-zA-Z0-9._/-]+$/.test(value); -} - -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 countCompletedBeginnerIssues(github, owner, repo, username) { - if ( - !isSafeSearchToken(owner) || - !isSafeSearchToken(repo) || - !isSafeSearchToken(username) || - !shared.isSafeLabel(BEGINNER_LABEL) - ) { - console.log('Invalid search inputs', { - owner, - repo, - username, - label: BEGINNER_LABEL, - }); - return null; - } - - try { - const searchQuery = [ - `repo:${owner}/${repo}`, - `label:"${BEGINNER_LABEL}"`, - 'is:issue', - 'is:closed', - `assignee:${username}`, - ].join(' '); - - console.log('GraphQL search query:', searchQuery); - - const result = await github.graphql( - ` - query ($searchQuery: String!) { - search(type: ISSUE, query: $searchQuery) { - issueCount - } - } - `, - { searchQuery } - ); - - const count = result?.search?.issueCount ?? 0; - - console.log(`Completed Beginner issues for ${username}: ${count}`); - - return count; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log( - `Failed to count Beginner issues 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 Beginner Issue so we can make sure you have a smooth on-ramp. - -You've completed **${completedCount}** Beginner Issue${plural} so far. -Once you wrap up your first Beginner issue, 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 countCompletedBeginnerIssues(github, owner, repo, mentee); - - if (completedCount === null) { - return console.log(`Skipping guard for @${mentee} on issue #${issue.number} due to API error when verifying Beginner issues.`); - } - - if(REQUIRED_BEGINNER_ISSUE_COUNT === 0){ - return console.log(`Skipping guard for @${mentee} on issue #${issue.number}: beginner requirement temporarily disabled`) - } - - if (completedCount >= REQUIRED_BEGINNER_ISSUE_COUNT) { - console.log(`✅ ${mentee} has completed ${completedCount} Beginner issues. Assignment allowed.`); - return; - } - - console.log(`❌ ${mentee} has completed ${completedCount} Beginner issues. 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 Beginner issue 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 Beginner issue 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/workflows/bot-intermediate-assignment.yml b/.github/workflows/bot-intermediate-assignment.yml deleted file mode 100644 index aa3a4701c..000000000 --- a/.github/workflows/bot-intermediate-assignment.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Prevents intermediate issues from being assigned to contributors without prior GFI experience. -name: Intermediate Issue Assignment Guard - -on: - issues: - types: - - assigned - workflow_dispatch: - inputs: - dry_run: - description: 'Run without making changes' - required: false - default: true - type: boolean - -permissions: - issues: write - contents: read - -jobs: - enforce-intermediate-guard: - concurrency: - group: intermediate-guard-${{ github.event.issue.number || github.run_id }} - cancel-in-progress: false - if: > - contains(github.event.issue.labels.*.name, 'skill: intermediate') - runs-on: hl-sdk-py-lin-md - steps: - - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Enforce intermediate assignment guard - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - INTERMEDIATE_LABEL: 'skill: intermediate' - BEGINNER_LABEL: 'skill: beginner' - GFI_LABEL: Good First Issue - INTERMEDIATE_COMMENT_MARKER: "" - INTERMEDIATE_EXEMPT_PERMISSIONS: admin,maintain,write,triage - DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' }}" - with: - script: | - const script = require('./.github/scripts/bot-intermediate-assignment.js'); - await script({ github, context }); From 56c1160ab38e45c4117510131c40aa0da5d2f8c1 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:50:17 +0530 Subject: [PATCH 09/11] refactor: add shared module exports Signed-off-by: Parv Ninama --- .github/scripts/bot-issue-assign-on-comment.js | 2 +- .github/scripts/shared/index.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/bot-issue-assign-on-comment.js b/.github/scripts/bot-issue-assign-on-comment.js index e397b57df..c195b96a2 100644 --- a/.github/scripts/bot-issue-assign-on-comment.js +++ b/.github/scripts/bot-issue-assign-on-comment.js @@ -2,7 +2,7 @@ // // Assigns user to a issue by commenting /assign -const { runAssignmentFlow } = require('./shared/core/issue-assign-core.js'); +const { runAssignmentFlow } = require('./shared'); module.exports = async ({ github, context }) => { try { diff --git a/.github/scripts/shared/index.js b/.github/scripts/shared/index.js index 51bc24c2b..4b0a0baf1 100644 --- a/.github/scripts/shared/index.js +++ b/.github/scripts/shared/index.js @@ -1,5 +1,6 @@ // core const { getRecommendedIssues } = require('./core/recommendation'); +const { runAssignmentFlow } = require('./core/issue-assign'); // helpers const { getHighestSkillLevelKey } = require('./helpers/utils'); @@ -15,6 +16,7 @@ const { CONFIG } = require('./config'); module.exports = { getRecommendedIssues, + runAssignmentFlow, getHighestSkillLevelKey, buildRecommendationComment, postComment, From ae1c41d41c4a4156c0b887a884803de68d917f3b Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 05:55:43 +0530 Subject: [PATCH 10/11] refactor: remove old assignment workflows Signed-off-by: Parv Ninama --- .../bot-beginner-assign-on-comment.yml | 38 ------------------ .../workflows/bot-gfi-assign-on-comment.yml | 39 ------------------- 2 files changed, 77 deletions(-) delete mode 100644 .github/workflows/bot-beginner-assign-on-comment.yml delete mode 100644 .github/workflows/bot-gfi-assign-on-comment.yml diff --git a/.github/workflows/bot-beginner-assign-on-comment.yml b/.github/workflows/bot-beginner-assign-on-comment.yml deleted file mode 100644 index 216475322..000000000 --- a/.github/workflows/bot-beginner-assign-on-comment.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Beginner Assign on /assign - -on: - issue_comment: - types: - - created - -permissions: - issues: write - contents: read - -jobs: - beginner-assign: - # Only run on issue comments (not PR comments) - if: github.event.issue.pull_request == null - - runs-on: hl-sdk-py-lin-md - - # Prevent race conditions unique to beginner assignment - concurrency: - group: beginner-assign-${{ github.event.issue.number }} - cancel-in-progress: false - - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Run Beginner /assign handler - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const script = require('./.github/scripts/bot-beginner-assign-on-comment.js'); - await script({ github, context }); diff --git a/.github/workflows/bot-gfi-assign-on-comment.yml b/.github/workflows/bot-gfi-assign-on-comment.yml deleted file mode 100644 index 6c9e62362..000000000 --- a/.github/workflows/bot-gfi-assign-on-comment.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: GFI Assign on /assign - -on: - issue_comment: - types: - - created - -permissions: - issues: write - contents: read - -jobs: - gfi-assign: - # Only run on issue comments (not PR comments) - if: github.event.issue.pull_request == null - - runs-on: hl-sdk-py-lin-md - - # Prevent assignment-limit races across different issues by processing - # one GFI assignment workflow at a time for this repository. - concurrency: - group: gfi-assign-${{ github.repository }} - cancel-in-progress: false - - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Run GFI /assign handler - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const script = require('./.github/scripts/bot-gfi-assign-on-comment.js'); - await script({ github, context }); From 9583c023d55a6afd700974c8b23661381e455ea5 Mon Sep 17 00:00:00 2001 From: Parv Ninama Date: Tue, 21 Jul 2026 06:15:07 +0530 Subject: [PATCH 11/11] fix: update shared validation import Signed-off-by: Parv Ninama --- .github/scripts/shared/api/github-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index 105edf430..41a0c93ee 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- const { CONFIG } = require('../config'); -const { isSafeSearchToken } = require("./validation"); +const { isSafeSearchToken } = require("../helpers/validation"); /** * Counts closed issues historically assigned to a contributor at a given label,