Fixed auto assign workflow #27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto Assign & Unassign (Org-wide Limit) | |
| on: | |
| issue_comment: | |
| types: [created] | |
| jobs: | |
| handle-comment: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Handle /assign or /unassign | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.ORG_ACCESS_TOKEN }} | |
| script: | | |
| const comment = context.payload.comment; | |
| const body = comment.body.trim(); | |
| const user = comment.user.login; | |
| const association = context.payload.comment.author_association; | |
| const issue = context.payload.issue || context.payload.pull_request; | |
| const issueNumber = issue.number; | |
| const { owner, repo } = context.repo; | |
| // Ignore bots or external contributors | |
| if (comment.user.type === "Bot" || association === "NONE") { | |
| console.log("Ignoring bot or external contributor"); | |
| return; | |
| } | |
| const isAssign = body.startsWith("/assign"); | |
| const isUnassign = body.startsWith("/unassign"); | |
| if (!isAssign && !isUnassign) { | |
| console.log("Not an /assign or /unassign command"); | |
| return; | |
| } | |
| if (isUnassign) { | |
| const currentAssignees = issue.assignees.map(a => a.login); | |
| if (!currentAssignees.includes(user)) { | |
| console.log(`${user} is not assigned, skipping unassign`); | |
| return; | |
| } | |
| try { | |
| await github.rest.issues.removeAssignees({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| assignees: [user], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: `✅ Unassigned @${user} successfully.`, | |
| }); | |
| console.log(`Unassigned ${user} from #${issueNumber}`); | |
| } catch (error) { | |
| console.error(`Error unassigning user ${user} from issue #${issueNumber}:`, error); | |
| // Attempt to create a fallback comment about the failure | |
| try { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: `❌ Failed to unassign @${user}. Please try again or contact a maintainer.`, | |
| }); | |
| } catch (commentError) { | |
| console.error(`Failed to create error comment for user ${user} on issue #${issueNumber}:`, commentError); | |
| } | |
| } | |
| return; | |
| } | |
| // For /assign | |
| if (issue.assignees.length > 0) { | |
| console.log("Already assigned, skipping."); | |
| return; | |
| } | |
| console.log(`Checking org-wide assignments for ${user}...`); | |
| let totalAssigned = 0; | |
| try { | |
| // Fetch all repos for the org | |
| const repos = await github.paginate(github.rest.repos.listForOrg, { | |
| org: owner, | |
| type: "all", | |
| per_page: 100, | |
| }); | |
| for (const r of repos) { | |
| const assignedIssues = await github.paginate( | |
| github.rest.issues.listForRepo, | |
| { | |
| owner, | |
| repo: r.name, | |
| state: "open", | |
| assignee: user, | |
| per_page: 100, | |
| } | |
| ); | |
| totalAssigned += assignedIssues.length; | |
| } | |
| } catch (error) { | |
| console.error("Error fetching org-wide assignments:", error); | |
| // Notify the user that the org-wide check failed | |
| try { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: `❌ **Org-wide assignment check failed**\n\n` + | |
| `Could not verify how many issues @${user} currently has assigned across the organization.\n\n` + | |
| `**Error:** ${error.message || 'Unknown error'}\n\n` + | |
| `Please contact a maintainer or try again later.`, | |
| }); | |
| } catch (commentError) { | |
| console.error(`Failed to post error comment for user ${user} on issue #${issueNumber}:`, commentError); | |
| } | |
| // Mark the workflow run as failed | |
| core.setFailed(`Org-wide assignment check failed: ${error.message || error}`); | |
| return; | |
| } | |
| console.log(`${user} currently has ${totalAssigned} open issues assigned across ${owner}`); | |
| // KNOWN RACE CONDITION: There is a time window between calculating totalAssigned | |
| // above and actually adding the assignee below where another workflow run (triggered | |
| // by a different /assign comment) can assign the same user to a different issue. | |
| // This distributed execution limitation means the limit can be transiently exceeded. | |
| // | |
| // Mitigation options: | |
| // 1. Accept small transient violations - simplest approach, limit is eventually | |
| // enforced and violations are rare/temporary in practice. | |
| // 2. Use a centralized locking service (e.g., Redis, DynamoDB) to serialize | |
| // assignment decisions across workflow runs. | |
| // 3. Implement retry/backoff: re-check totalAssigned immediately before addAssignees, | |
| // and if another assignment snuck in, abort and notify the user. | |
| // 4. Use GitHub's assignment as a "lock" - assign first, then check and unassign | |
| // if over limit (but this creates noise in notifications). | |
| // | |
| // Current implementation: Option 1 (accept transient violations for simplicity). | |
| if (totalAssigned >= 2) { | |
| const message = `⚠️ @${user} already has ${totalAssigned} open assignments across the org. Please finish or unassign before taking new ones.`; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: message, | |
| }); | |
| console.log("Limit reached, skipping assignment"); | |
| return; | |
| } | |
| try { | |
| await github.rest.issues.addAssignees({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| assignees: [user], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: `✅ Assigned @${user} successfully.`, | |
| }); | |
| console.log(`Assigned ${user} to #${issueNumber}`); | |
| } catch (error) { | |
| console.error(`Error assigning user ${user} to issue #${issueNumber}:`, error); | |
| // Attempt to create a failure comment to inform the user | |
| try { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: `❌ **Assignment failed**\n\n` + | |
| `Could not assign @${user} to this issue.\n\n` + | |
| `**Error:** ${error.message || 'Unknown error'}\n\n` + | |
| `Please try again or contact a maintainer.`, | |
| }); | |
| } catch (commentError) { | |
| console.error(`Failed to post error comment for user ${user} on issue #${issueNumber}:`, commentError); | |
| // Fall back to marking the workflow as failed if we can't even comment | |
| core.setFailed(`Assignment failed for ${user} on issue #${issueNumber}: ${error.message || error}`); | |
| } | |
| } |