diff --git a/.github/workflows/auto-assign-issue.yml b/.github/workflows/auto-assign-issue.yml deleted file mode 100644 index 3211308..0000000 --- a/.github/workflows/auto-assign-issue.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Auto-Assign Issues - -on: - issues: - types: [commented] - -permissions: - issues: write - contents: read - -jobs: - auto-assign: - runs-on: ubuntu-latest - steps: - - name: Auto-assign issue to commenter - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const issue = context.payload.issue; - const comment = context.payload.comment; - const commenter = comment.user.login; - const issueAuthor = issue.user.login; - - // Trigger phrases (case-insensitive) - const triggerPhrases = [ - 'i want to work on this', - "i'd like to work on this", - 'can i work on this', - 'assign me', - "i'll take this", - 'let me handle this', - 'i can work on this' - ]; - - const commentBody = comment.body.toLowerCase().trim(); - - // Check if comment contains a trigger phrase - const hasTrigger = triggerPhrases.some(phrase => - commentBody.includes(phrase) - ); - - if (!hasTrigger) { - console.log('No trigger phrase found. Skipping.'); - return; - } - - console.log(`Trigger phrase found from @${commenter}`); - - // Check if the commenter is a bot - if (comment.user.type === 'Bot') { - console.log('Commenter is a bot. Skipping.'); - return; - } - - // Check if issue is already assigned - if (issue.assignees && issue.assignees.length > 0) { - const assignedNames = issue.assignees.map(a => a.login).join(', '); - console.log(`Issue already assigned to: ${assignedNames}. Skipping.`); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `@${commenter} This issue is already assigned to @${assignedNames}. Please pick another issue or wait for the assignment to be released.` - }); - return; - } - - // Assign the issue to the commenter - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: [commenter] - }); - - console.log(`Assigned issue #${issue.number} to @${commenter}`); - - // Add 'assigned' label - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['assigned'] - }); - console.log('Added "assigned" label.'); - } catch (error) { - console.log(`Could not add label: ${error.message}`); - } - - // Post confirmation comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: [ - `@${commenter} has been assigned to this issue! 🎉`, - '', - '**Next steps:**', - '1. Fork the repository', - '2. Create a feature branch (`git checkout -b feature/your-feature`)', - '3. Make your changes and commit (`git commit -m "feat: add your feature"`)', - '4. Push to your fork and open a Pull Request', - '', - 'Please read the [Contributing Guidelines](../blob/main/CONTRIBUTING.md) before starting.', - '', - 'Good luck! Happy coding! 🚀' - ].join('\n') - }); diff --git a/.github/workflows/issue-claim.yml b/.github/workflows/issue-claim.yml new file mode 100644 index 0000000..3949efc --- /dev/null +++ b/.github/workflows/issue-claim.yml @@ -0,0 +1,673 @@ +# ============================================================================= +# Issue Claim System +# ============================================================================= +# Production-ready GitHub Actions workflow for automatic issue claiming. +# +# Supports three commands via issue comments: +# /claim — Claim an unassigned issue +# /assign @username — Assign issue to a user (maintainer only) +# /release — Release a claimed issue back to the pool +# +# Trigger: issue_comment (fires when a comment is created on an issue) +# Uses: actions/github-script@v7 for inline JavaScript execution +# +# Author: OpenAgentHQ +# License: MIT +# ============================================================================= + +name: Issue Claim System + +# --------------------------------------------------------------------------- +# Trigger Configuration +# --------------------------------------------------------------------------- +# Uses issue_comment event which fires on issue AND PR comments. +# We add guards in the workflow to skip PR comments. +on: + issue_comment: + types: [created] + +# --------------------------------------------------------------------------- +# Permissions (Least Privilege) +# --------------------------------------------------------------------------- +# - issues: write — Assign/unassign users, add labels, post comments +# - contents: read — Read repository metadata (required by github-script) +# - pull-requests: read — Detect PR vs issue comments (to skip PRs) +permissions: + issues: write + contents: read + pull-requests: read + +# --------------------------------------------------------------------------- +# Concurrency Control +# --------------------------------------------------------------------------- +# Prevents race conditions when multiple comments arrive simultaneously. +# Groups by issue number so different issues can process in parallel. +concurrency: + group: issue-claim-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + # ========================================================================= + # Issue Claim Handler + # ========================================================================= + # Single job that routes commands to appropriate handlers. + # All logic is modular — each command is a separate pure function. + issue-claim: + name: Process Issue Command + runs-on: ubuntu-latest + + # Only run if the comment starts with a slash command + if: startsWith(github.event.comment.body, '/') + + steps: + # ===================================================================== + # Step 1: Initialize and Route Command + # ===================================================================== + - name: Route and Execute Command + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // ================================================================= + // MODULE: Context Extraction + // Pure functions to extract data from the GitHub event payload. + // ================================================================= + + /** + * Extract issue context from the event payload. + * Returns an immutable context object. + * + * @param {Object} payload - GitHub event payload + * @returns {Object} Issue context (frozen for immutability) + */ + const extractIssueContext = (payload) => { + const issue = payload.issue; + const comment = payload.comment; + + const ctx = { + // Issue metadata + issueNumber: issue.number, + issueTitle: issue.title, + issueState: issue.state, + issueUrl: issue.html_url, + + // Author info + issueAuthor: issue.user.login, + issueAuthorType: comment.user.type, + + // Commenter info + commenter: comment.user.login, + commenterType: comment.user.type, + + // Assignees + assignees: (issue.assignees || []).map(a => a.login), + hasAssignees: (issue.assignees || []).length > 0, + + // Labels + labels: (issue.labels || []).map(l => l.name), + + // Comment + commentBody: comment.body.trim(), + commentId: comment.id, + + // Repository info + owner: context.repo.owner, + repo: context.repo.repo, + + // PR detection (issue_comment fires on PRs too) + isPullRequest: !!payload.issue.pull_request, + }; + + // Freeze to prevent mutation + return Object.freeze(ctx); + }; + + /** + * Parse the command from comment body. + * Extracts the command name and any arguments. + * + * @param {string} body - Comment body text + * @returns {Object} Parsed command with name and args + */ + const parseCommand = (body) => { + const trimmed = body.trim(); + const parts = trimmed.split(/\s+/); + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + + // Extract @mention from args + const mentionArg = args.find(a => a.startsWith('@')); + const targetUser = mentionArg ? mentionArg.slice(1) : null; + + return Object.freeze({ + command, + args, + mentionArg, + targetUser, + raw: trimmed, + }); + }; + + + // ================================================================= + // MODULE: Logging + // Structured logging for debugging and audit trail. + // ================================================================= + + /** + * Log a message with structured prefix. + * + * @param {string} level - Log level (INFO, WARN, ERROR, DEBUG) + * @param {string} message - Human-readable message + * @param {Object} data - Additional structured data + */ + const log = (level, message, data = {}) => { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level}]`; + const dataStr = Object.keys(data).length > 0 + ? `\n Data: ${JSON.stringify(data, null, 2)}` + : ''; + console.log(`${prefix} ${message}${dataStr}`); + }; + + + // ================================================================= + // MODULE: Permission Checks + // Functions to verify user permissions and roles. + // ================================================================= + + /** + * Check if a user is a maintainer (org member with write access). + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {string} username - User to check + * @returns {Promise} True if user is a maintainer + */ + const isMaintainer = async (github, ctx, username) => { + try { + // Check if user has admin/write permission on the repo + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: ctx.owner, + repo: ctx.repo, + username: username, + }); + + const allowedPermissions = ['admin', 'write']; + const hasPermission = allowedPermissions.includes(permission.permission); + + log('INFO', `Permission check for @${username}`, { + permission: permission.permission, + isMaintainer: hasPermission, + }); + + return hasPermission; + } catch (error) { + log('ERROR', `Permission check failed for @${username}`, { + error: error.message, + }); + return false; + } + }; + + + // ================================================================= + // MODULE: Label Management + // Safe label operations that don't fail if labels don't exist. + // ================================================================= + + /** + * Safely add a label to an issue. + * Skips gracefully if the label doesn't exist in the repository. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {string} labelName - Name of the label to add + */ + const safelyAddLabel = async (github, ctx, labelName) => { + try { + await github.rest.issues.addLabels({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + labels: [labelName], + }); + log('INFO', `Added label "${labelName}"`, { + issue: ctx.issueNumber, + }); + } catch (error) { + // Label might not exist — log and continue + log('WARN', `Could not add label "${labelName}" (may not exist)`, { + error: error.message, + issue: ctx.issueNumber, + }); + } + }; + + /** + * Safely remove a label from an issue. + * Skips gracefully if the label doesn't exist on the issue. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {string} labelName - Name of the label to remove + */ + const safelyRemoveLabel = async (github, ctx, labelName) => { + try { + await github.rest.issues.removeLabel({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + name: labelName, + }); + log('INFO', `Removed label "${labelName}"`, { + issue: ctx.issueNumber, + }); + } catch (error) { + // Label might not exist on issue — log and continue + log('WARN', `Could not remove label "${labelName}" (may not exist on issue)`, { + error: error.message, + issue: ctx.issueNumber, + }); + } + }; + + + // ================================================================= + // MODULE: Comment Helpers + // Functions to post formatted comments on issues. + // ================================================================= + + /** + * Post a comment on the issue. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {string} body - Comment body (Markdown supported) + */ + const postComment = async (github, ctx, body) => { + try { + await github.rest.issues.createComment({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + body: body, + }); + log('INFO', 'Posted comment', { + issue: ctx.issueNumber, + bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : ''), + }); + } catch (error) { + log('ERROR', 'Failed to post comment', { + error: error.message, + issue: ctx.issueNumber, + }); + } + }; + + + // ================================================================= + // MODULE: Command Handlers + // Each handler is an isolated function that processes one command. + // ================================================================= + + /** + * Handle /claim command. + * Assigns the issue to the commenter if not already assigned. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + */ + const handleClaim = async (github, ctx) => { + log('INFO', 'Processing /claim command', { + commenter: ctx.commenter, + issue: ctx.issueNumber, + currentAssignees: ctx.assignees, + }); + + // Guard: Issue already assigned + if (ctx.hasAssignees) { + const assigneeList = ctx.assignees.join(', @'); + log('INFO', 'Issue already assigned, rejecting claim', { + assignees: ctx.assignees, + }); + + await postComment(github, ctx, + `❌ This issue is already assigned to @${assigneeList}.\n\n` + + `If there is no activity for 7 days it may become available again.\n\n` + + `Please check other open issues.` + ); + return; + } + + // Guard: Commenter is already the assignee (edge case) + if (ctx.assignees.includes(ctx.commenter)) { + log('INFO', 'Commenter is already assigned', { + commenter: ctx.commenter, + }); + + await postComment(github, ctx, + `â„šī¸ You are already assigned to this issue.` + ); + return; + } + + // Assign the issue to the commenter + try { + await github.rest.issues.addAssignees({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + assignees: [ctx.commenter], + }); + + log('INFO', 'Assigned issue to commenter', { + assignee: ctx.commenter, + issue: ctx.issueNumber, + }); + + // Add in-progress label + await safelyAddLabel(github, ctx, 'in-progress'); + + // Post success comment + await postComment(github, ctx, + `🎉 Thanks @${ctx.commenter}!\n\n` + + `This issue has been assigned to you.\n\n` + + `Happy coding! 🚀` + ); + } catch (error) { + log('ERROR', 'Failed to assign issue', { + error: error.message, + issue: ctx.issueNumber, + }); + + await postComment(github, ctx, + `âš ī¸ Failed to assign this issue. Please try again or contact a maintainer.` + ); + } + }; + + /** + * Handle /assign @username command. + * Allows maintainers to assign issues to specific users. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {Object} cmd - Parsed command + */ + const handleAssign = async (github, ctx, cmd) => { + log('INFO', 'Processing /assign command', { + commenter: ctx.commenter, + targetUser: cmd.targetUser, + issue: ctx.issueNumber, + }); + + // Guard: No target user specified + if (!cmd.targetUser) { + log('WARN', 'No target user specified', { args: cmd.args }); + + await postComment(github, ctx, + `âš ī¸ Please specify a user to assign.\n\n` + + `Usage: \`/assign @username\`` + ); + return; + } + + // Guard: Permission check — only maintainers can use /assign + const commenterIsMaintainer = await isMaintainer(github, ctx, ctx.commenter); + if (!commenterIsMaintainer) { + log('WARN', 'Non-maintainer tried to use /assign', { + commenter: ctx.commenter, + }); + + await postComment(github, ctx, + `❌ Only maintainers can manually assign issues.` + ); + return; + } + + // Guard: Target user is already assigned + if (ctx.assignees.includes(cmd.targetUser)) { + log('INFO', 'Target user already assigned', { + targetUser: cmd.targetUser, + }); + + await postComment(github, ctx, + `â„šī¸ @${cmd.targetUser} is already assigned to this issue.` + ); + return; + } + + // Assign the target user + try { + await github.rest.issues.addAssignees({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + assignees: [cmd.targetUser], + }); + + log('INFO', 'Assigned issue to target user', { + assignee: cmd.targetUser, + issue: ctx.issueNumber, + assignedBy: ctx.commenter, + }); + + // Add in-progress label + await safelyAddLabel(github, ctx, 'in-progress'); + + // Post confirmation + await postComment(github, ctx, + `✅ @${cmd.targetUser} has been assigned to this issue by @${ctx.commenter}.\n\n` + + `Happy coding! 🚀` + ); + } catch (error) { + log('ERROR', 'Failed to assign issue', { + error: error.message, + targetUser: cmd.targetUser, + issue: ctx.issueNumber, + }); + + await postComment(github, ctx, + `âš ī¸ Failed to assign @${cmd.targetUser}. Please check that the username is valid.` + ); + } + }; + + /** + * Handle /release command. + * Removes the assignee and in-progress label from the issue. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + */ + const handleRelease = async (github, ctx) => { + log('INFO', 'Processing /release command', { + commenter: ctx.commenter, + issue: ctx.issueNumber, + currentAssignees: ctx.assignees, + }); + + // Guard: No one is assigned + if (!ctx.hasAssignees) { + log('INFO', 'Issue has no assignees', { issue: ctx.issueNumber }); + + await postComment(github, ctx, + `â„šī¸ This issue is not currently assigned to anyone.\n\n` + + `Use \`/claim\` to assign it to yourself.` + ); + return; + } + + // Guard: Only the current assignee can release + if (!ctx.assignees.includes(ctx.commenter)) { + const assigneeList = ctx.assignees.join(', @'); + log('WARN', 'Non-assignee tried to release', { + commenter: ctx.commenter, + assignees: ctx.assignees, + }); + + await postComment(github, ctx, + `❌ Only the current assignee can release this issue.\n\n` + + `Currently assigned to: @${assigneeList}` + ); + return; + } + + // Remove assignee + try { + await github.rest.issues.removeAssignees({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + assignees: [ctx.commenter], + }); + + log('INFO', 'Removed assignee', { + assignee: ctx.commenter, + issue: ctx.issueNumber, + }); + + // Remove in-progress label + await safelyRemoveLabel(github, ctx, 'in-progress'); + + // Post confirmation + await postComment(github, ctx, + `🔄 Issue has been released and is now available for contributors.` + ); + } catch (error) { + log('ERROR', 'Failed to release issue', { + error: error.message, + issue: ctx.issueNumber, + }); + + await postComment(github, ctx, + `âš ī¸ Failed to release this issue. Please try again or contact a maintainer.` + ); + } + }; + + + // ================================================================= + // MODULE: Command Router + // Routes parsed commands to appropriate handlers. + // ================================================================= + + /** + * Route a command to its handler. + * + * @param {Object} github - Octokit client + * @param {Object} ctx - Issue context + * @param {Object} cmd - Parsed command + */ + const routeCommand = async (github, ctx, cmd) => { + log('INFO', 'Routing command', { + command: cmd.command, + commenter: ctx.commenter, + issue: ctx.issueNumber, + }); + + switch (cmd.command) { + case '/claim': + await handleClaim(github, ctx); + break; + + case '/assign': + await handleAssign(github, ctx, cmd); + break; + + case '/release': + await handleRelease(github, ctx); + break; + + default: + log('DEBUG', 'Unknown command, ignoring', { + command: cmd.command, + }); + break; + } + }; + + + // ================================================================= + // MODULE: Guard Clauses + // Pre-flight checks before processing any command. + // ================================================================= + + /** + * Validate the context before processing. + * Returns null if valid, or a rejection message if invalid. + * + * @param {Object} ctx - Issue context + * @returns {{ valid: boolean, reason?: string }} + */ + const validateContext = (ctx) => { + // Guard: Skip PR comments (issue_comment fires on PRs too) + if (ctx.isPullRequest) { + return { valid: false, reason: 'PR comment detected, skipping' }; + } + + // Guard: Skip if issue is closed + if (ctx.issueState === 'closed') { + return { valid: false, reason: 'Issue is closed, skipping' }; + } + + // Guard: Skip if commenter is a bot + if (ctx.commenterType === 'Bot') { + return { valid: false, reason: 'Commenter is a bot, skipping' }; + } + + // Guard: Skip if comment doesn't start with / + if (!ctx.commentBody.startsWith('/')) { + return { valid: false, reason: 'Not a command (no / prefix), skipping' }; + } + + return { valid: true }; + }; + + + // ================================================================= + // MAIN: Entry Point + // ================================================================= + // Extract context, validate, parse command, and route. + + try { + // Step 1: Extract immutable context + const ctx = extractIssueContext(context.payload); + + log('INFO', 'Issue comment received', { + issue: ctx.issueNumber, + title: ctx.issueTitle, + commenter: ctx.commenter, + commenterType: ctx.commenterType, + state: ctx.issueState, + isPR: ctx.isPullRequest, + assignees: ctx.assignees, + }); + + // Step 2: Validate context + const validation = validateContext(ctx); + if (!validation.valid) { + log('INFO', 'Skipping: ' + validation.reason); + return; + } + + // Step 3: Parse command + const cmd = parseCommand(ctx.commentBody); + + log('INFO', 'Command parsed', { + command: cmd.command, + targetUser: cmd.targetUser, + args: cmd.args, + }); + + // Step 4: Route to handler + await routeCommand(github, ctx, cmd); + + } catch (error) { + // Catch-all error handler — never crash the workflow + log('ERROR', 'Unexpected error in issue claim workflow', { + error: error.message, + stack: error.stack, + }); + } diff --git a/docs/14_issue_claim_system.md b/docs/14_issue_claim_system.md new file mode 100644 index 0000000..7b9416d --- /dev/null +++ b/docs/14_issue_claim_system.md @@ -0,0 +1,284 @@ +# Issue Claim System + +> Automatic issue assignment via GitHub Actions using slash commands. + +## Overview + +The Issue Claim System allows contributors to claim, assign, and release issues directly from issue comments using slash commands. It runs entirely on GitHub Actions — no external servers or paid services required. + +## How It Works + +1. A contributor comments on an open issue with a slash command +2. GitHub Actions triggers the workflow +3. The workflow validates the context (PR vs issue, open vs closed, bot vs human) +4. The command is parsed and routed to the appropriate handler +5. The issue is assigned/released and a confirmation comment is posted + +## Supported Commands + +### `/claim` + +Claims an unassigned issue for the commenter. + +**Usage:** +``` +/claim +``` + +**Behavior:** +- ✅ If issue is unassigned → assigns to commenter, adds `in-progress` label, posts success message +- ❌ If issue is already assigned → posts rejection with current assignee info +- ❌ If commenter is a bot → silently ignored +- ❌ If issue is closed → silently ignored + +**Example response:** +> 🎉 Thanks @username! +> +> This issue has been assigned to you. +> +> Happy coding! 🚀 + +--- + +### `/assign @username` + +Assigns an issue to a specific user. **Maintainer-only command.** + +**Usage:** +``` +/assign @target-user +``` + +**Behavior:** +- ✅ If commenter is a maintainer → assigns target user, adds `in-progress` label +- ❌ If commenter is not a maintainer → rejected with permission error +- ❌ If no target user specified → shows usage instructions +- ❌ If target user already assigned → shows info message + +**Example response (success):** +> ✅ @target-user has been assigned to this issue by @maintainer. +> +> Happy coding! 🚀 + +**Example response (rejected):** +> ❌ Only maintainers can manually assign issues. + +--- + +### `/release` + +Releases a claimed issue back to the contributor pool. **Current assignee only.** + +**Usage:** +``` +/release +``` + +**Behavior:** +- ✅ If commenter is the assignee → removes assignee, removes `in-progress` label +- ❌ If commenter is not the assignee → rejected with current assignee info +- ❌ If issue is not assigned → shows info message + +**Example response:** +> 🔄 Issue has been released and is now available for contributors. + +## Labels + +The workflow manages these labels automatically: + +| Label | Added | Removed | +|-------|-------|---------| +| `in-progress` | On `/claim` or `/assign` | On `/release` | +| `good first issue` | Manual only | — | +| `help wanted` | Manual only | — | + +> **Note:** If a label doesn't exist in the repository, the workflow skips it gracefully without failing. + +## Required Permissions + +The workflow uses these GitHub Actions permissions: + +```yaml +permissions: + issues: write # Assign/unassign users, add labels, post comments + contents: read # Read repository metadata + pull-requests: read # Detect PR vs issue comments +``` + +These are **minimum required permissions** following the principle of least privilege. + +## Edge Cases Handled + +| Scenario | Behavior | +|----------|----------| +| Comment on a PR | Silently ignored (issue_comment fires on PRs too) | +| Comment on a closed issue | Silently ignored | +| Bot comments | Silently ignored | +| Non-slash comments | Silently ignored | +| Unknown commands (e.g., `/help`) | Silently ignored | +| Target user doesn't exist | Error message posted | +| Label doesn't exist | Warning logged, workflow continues | +| GitHub API failure | Error logged, user-friendly message posted | +| Multiple assignees | Handled correctly in all commands | +| Concurrent comments | Serialized via concurrency control | + +## Architecture + +``` +.github/workflows/issue-claim.yml +├── Context Extraction — Pure functions to extract data from event payload +├── Logging — Structured logging for debugging and audit +├── Permission Checks — Verify user roles and access +├── Label Management — Safe label operations (never fails) +├── Comment Helpers — Post formatted comments +├── Command Handlers — Isolated handlers for each command +│ ├── handleClaim — /claim logic +│ ├── handleAssign — /assign logic +│ └── handleRelease — /release logic +├── Command Router — Routes commands to handlers +├── Guard Clauses — Pre-flight validation +└── Main Entry Point — Orchestrates the workflow +``` + +## Security Considerations + +1. **Least privilege permissions** — Only requests permissions needed for the workflow +2. **Maintainer-only commands** — `/assign` verifies collaborator permission level +3. **Bot detection** — Ignores comments from GitHub Apps bots +4. **PR guard** — Skips PR comments to prevent unintended assignments +5. **No secrets exposed** — Only uses `GITHUB_TOKEN` (auto-generated) +6. **Input sanitization** — Comment body is trimmed and parsed safely +7. **Concurrency control** — Prevents race conditions on simultaneous comments + +## Testing + +### Manual Testing + +1. **Test `/claim`:** + - Open an unassigned issue + - Comment `/claim` + - Verify: Issue assigned to you, `in-progress` label added, success comment posted + +2. **Test `/claim` on assigned issue:** + - Open an issue assigned to someone else + - Comment `/claim` + - Verify: Rejection comment with current assignee info + +3. **Test `/assign`:** + - Open an unassigned issue (as a maintainer) + - Comment `/assign @some-user` + - Verify: User assigned, `in-progress` label added, confirmation comment + +4. **Test `/assign` (non-maintainer):** + - Open an unassigned issue (as a non-maintainer) + - Comment `/assign @some-user` + - Verify: Permission error message + +5. **Test `/release`:** + - Open an issue assigned to you + - Comment `/release` + - Verify: Assignee removed, `in-progress` label removed, confirmation comment + +6. **Test PR comment:** + - Open a PR + - Comment `/claim` + - Verify: Workflow ignores the comment + +### Automated Testing + +To add automated tests, create a test workflow that: +1. Uses `peter-evans/create-or-update-comment` to post test comments +2. Uses `actions/github-script` to verify issue state +3. Cleans up test issues after testing + +## Customization + +### Adding New Commands + +1. Add a new handler function in the `Command Handlers` module +2. Add the command to the `routeCommand` switch statement +3. Update the `parseCommand` function if the command has special syntax + +Example: +```javascript +const handleThank = async (github, ctx) => { + await postComment(github, ctx, + `Thank you @${ctx.commenter} for your contribution! 🙏` + ); +}; + +// In routeCommand: +case '/thank': + await handleThank(github, ctx); + break; +``` + +### Adding Discord/Slack Notifications + +Add a notification step after successful assignment: + +```javascript +// After handleClaim succeeds: +const notifyDiscord = async (ctx) => { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) return; + + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `🎉 @${ctx.commenter} claimed issue #${ctx.issueNumber}: ${ctx.issueTitle}` + }), + }); +}; +``` + +### Adding Auto-Unassign (7-Day Inactivity) + +Use a scheduled workflow to check for inactive assignments: + +```yaml +on: + schedule: + - cron: '0 0 * * *' # Daily at midnight + +jobs: + auto-unassign: + runs-on: ubuntu-latest + steps: + - name: Check inactive assignments + uses: actions/github-script@v7 + with: + script: | + // Query issues with in-progress label + // Check last activity date + // Release if inactive for 7+ days +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Workflow doesn't trigger | Verify the comment starts with `/` and the issue is open | +| Assignment fails | Check that `GITHUB_TOKEN` has `issues: write` permission | +| Label not added | Verify the label exists in repository settings | +| `/assign` rejected | Verify you have write access to the repository | +| Comment not posted | Check workflow logs for API errors | + +## Future Enhancements + +- [ ] Auto-unassign after 7 days of inactivity +- [ ] Contributor leaderboard tracking +- [ ] Welcome messages for first-time contributors +- [ ] Automatic thank-you comments after merge +- [ ] Discord/Slack notifications +- [ ] Metrics collection (claim frequency, resolution time) +- [ ] Priority-based claiming (contributors with more merges get priority) +- [ ] Time-limited claims (auto-release after X days) + +## Related Files + +- `.github/workflows/issue-claim.yml` — Main workflow file +- `.github/ISSUE_TEMPLATE/` — Issue templates (optional) +- `CONTRIBUTING.md` — Contribution guidelines +- `CODE_OF_CONDUCT.md` — Community standards