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-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/scripts/bot-issue-assign-on-comment.js b/.github/scripts/bot-issue-assign-on-comment.js new file mode 100644 index 000000000..c195b96a2 --- /dev/null +++ b/.github/scripts/bot-issue-assign-on-comment.js @@ -0,0 +1,18 @@ +// .github/scripts/bot-issue-assign-on-comment.js +// +// Assigns user to a issue by commenting /assign + +const { runAssignmentFlow } = require('./shared'); + +module.exports = async ({ github, context }) => { + try { + await runAssignmentFlow({ github, context }); + } 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/api/github-api.js b/.github/scripts/shared/api/github-api.js index 96774f4e6..41a0c93ee 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("../helpers/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 new file mode 100644 index 000000000..ae8c9c1ea --- /dev/null +++ b/.github/scripts/shared/core/issue-assign.js @@ -0,0 +1,265 @@ +// .github/scripts/shared/issue-assign-core.js +// +// Shared engine for issue-comment assignment automation. +// +// 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 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'); + +const { + getOpenAssignments, + countCompletedIssuesWithLabel, + isRepoCollaborator, + postComment, + fetchAllComments, + assignIssue, +} = require('../api/github-api.js'); + +const { + buildAlreadyAssignedComment, + buildGuardComment, + buildLimitComment, + buildReminderComment, + buildSpamBlockedComment, + reminderMarkerFor, + guardMarkerFor, +} = require('../helpers/comment.js'); + +const { + isSpamUser, + spamUsersBlocked, + isSpamLimited, + getAssignmentLimit, +} = require('../helpers/spam.js'); + +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; +} + +function findHomeRepoConfig() { + return CONFIG.repos.find((r) => r.isHome) || CONFIG.repos[0]; +} + +/** + * 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; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +async function runAssignmentFlow({ github, context }) { + 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; + } + + 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 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; + + 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.`); + 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 marker = reminderMarkerFor(levelKey); + if (comments.some((c) => c.body?.includes(marker))) { + console.log('[assign-bot] Reminder already posted. Skipping.'); + return; + } + + const body = `${marker}\n${buildReminderComment(commenter)}`; + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'assign reminder'); + return; + } + + // ---- /assign command ---- + + // 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: homeRepoConfig.owner, + repo: homeRepoConfig.repo, + username: commenter, + label: prereqLabel, + }); + + 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 < levelConfig.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 marker = guardMarkerFor(levelKey); + if (!comments.some((c) => c.body?.includes(marker))) { + const body = `${marker}\n${buildGuardComment(commenter, { + owner, + repo: repoName, + prereqLabel, + prereqDisplayName, + requiredCount: levelConfig.requiredCount, + currentDisplayName: levelConfig.displayName, + })}`; + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'prerequisite guard'); + } + return; + } + } + + // Already assigned? + if (isAssigned) { + const body = buildAlreadyAssignedComment(commenter, issue, { owner, repo: repoName, label }); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'already-assigned notice'); + return; + } + + // Spam handling + const spamUser = isSpamUser(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 = buildSpamBlockedComment(commenter, { prereqDisplayName: gfiDisplayName }); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'spam restriction notice'); + return; + } + + 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 spamLimited = isSpamLimited(levelKey, spamUser); + const body = buildLimitComment(commenter, { maxAllowed, spamLimited }); + await postComment({ github, owner, repo: repoName, issueNumber, body }, 'limit warning'); + return; + } + + // Assign + try { + await assignIssue({ + github, + owner, + repo: repoName, + issueNumber, + username: commenter, + }); + } catch (error) { + console.error("[assign-bot] Failed to assign issue:", { + message: error.message, + }); + } + return; + +} + +module.exports = { + runAssignmentFlow, +}; 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/spam.js b/.github/scripts/shared/helpers/spam.js new file mode 100644 index 000000000..9b199caf2 --- /dev/null +++ b/.github/scripts/shared/helpers/spam.js @@ -0,0 +1,88 @@ +// --------------------------------------------------------------------------- +// 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 + * @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 + ) + ); +} + +module.exports = { + isSpamUser, + spamUsersBlocked, + getAssignmentLimit, + isSpamLimited, +}; 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, }; 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, 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 }); 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 }); 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 });