WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

test: add coverage for PluginRoutes.tsx to 100% #2081

test: add coverage for PluginRoutes.tsx to 100%

test: add coverage for PluginRoutes.tsx to 100% #2081

Workflow file for this run

name: Auto Assign & Unassign (Org-wide Limit)
on:
issue_comment:
types: [created]
env:
MAX_OPEN_ASSIGNMENTS: 2
jobs:
handle-comment:
runs-on: ubuntu-latest
# Only runs on issue comments, not those of pull requests
if: github.event.issue.pull_request == null
timeout-minutes: 15
permissions:
issues: write
pull-requests: write
steps:
- name: Handle /assign or /unassign
uses: actions/github-script@v7
with:
# ORG_ACCESS_TOKEN must have the following scopes:
# - repo (or public_repo if using public repos only)
# - org:read (to enumerate organization repos)
github-token: ${{ secrets.ORG_ACCESS_TOKEN }}
script: |
/**
* Auto-assign workflow for organization-wide issue assignment limits
*
* Features:
* - Self-assignment only: Users comment /assign to assign themselves
* - Organization-wide limit: Enforces MAX_OPEN_ASSIGNMENTS across all org repos
* - Race condition mitigation: Re-checks assignment count before final assignment
*
* Required GitHub Token Permissions (ORG_ACCESS_TOKEN):
* - repo (or public_repo for public repos only)
* - org:read (to enumerate organization repositories)
*
* Configuration:
* - MAX_OPEN_ASSIGNMENTS: Maximum open issues per user (default: 2)
*
* Commands:
* - /assign: Self-assign an issue (if under limit)
* - /unassign: Remove self-assignment from an issue
*
* Limitations:
* - Small race condition window exists between checks and assignment
* - Only self-assignment supported (cannot assign others)
* - Requires archived repos to be properly marked in GitHub
*/
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;
// Default assignment limit is 2 if not configured
const maxAssignments = parseInt(process.env.MAX_OPEN_ASSIGNMENTS || '2');
if (isNaN(maxAssignments)) {
throw new Error(`Invalid MAX_OPEN_ASSIGNMENTS value: "${process.env.MAX_OPEN_ASSIGNMENTS}". Must be a positive integer.`);
}
// Ignore bots only
if (comment.user.type === "Bot") {
console.log("Ignoring bot");
return;
}
/**
* Check if a user has any merged PRs in the repository
* @param {object} github - GitHub API client
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} username - GitHub username to check
* @returns {Promise<boolean>} - True if user has merged PRs, false otherwise
*/
async function hasMergedPRs(github, owner, repo, username) {
try {
// Use GitHub Search API to find merged PRs authored by the user
const query = `repo:${owner}/${repo} type:pr is:merged author:${username}`;
const result = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 1, // We only need to know if at least one exists
});
const count = result.data.total_count;
console.log(`User ${username} has ${count} merged PR(s) in ${owner}/${repo}`);
return count > 0;
} catch (error) {
console.error(`Error checking merged PRs for ${username}:`, error);
// On error, return false to be safe (deny access)
return false;
}
}
const isAssign = body.startsWith("/assign");
const isUnassign = body.startsWith("/unassign");
if (!isAssign && !isUnassign) {
console.log("Not an /assign or /unassign command");
return;
}
// After checking isAssign
if (isAssign && body.trim() !== "/assign") {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `❌ @${user} This workflow only supports self-assignment. Use \`/assign\` without specifying a username.`,
});
return;
}
/**
* NOTE
*
* List of possible user association values: https://docs.github.com/en/graphql/reference/enums
*
* COLLABORATOR
* Author has been invited to collaborate on the repository.
*
* CONTRIBUTOR
* Author has previously committed to the repository.
*
* FIRST_TIMER (Seems to be only applicable to PR commenters)
* Author has not previously committed to GitHub.
*
* FIRST_TIME_CONTRIBUTOR (Seems to be only applicable to PR commenters)
* Author has not previously committed to the repository.
*
* MANNEQUIN
* Author is a placeholder for an unclaimed user.
*
* MEMBER
* Author is a member of the organization that owns the repository.
*
* NONE (Default for non-PR commenters who don't match any of the other associations)
* Author has no association with the repository.
*
* OWNER
* Author is the owner of the repository.
*/
// Issues created by non-members require manual intervention by members for auto-assignment.
if (isAssign) {
const issueCreator = issue.user.login;
let issueCreatorAssociation;
// Fetch author_association via REST API since it's no longer in event payload
try {
const issueDetails = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
issueCreatorAssociation = issueDetails.data.author_association;
} catch (error) {
console.error(`Error fetching issue details for #${issueNumber}:`, error);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `❌ Failed to verify issue permissions. Please contact a maintainer or try again later.`,
});
return;
}
console.log(`Issue #${issueNumber} creator ${issueCreator} has association: ${issueCreatorAssociation}`);
// Only allow /assign on issues created by OWNER, MEMBER, COLLABORATOR or CONTRIBUTOR
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(issueCreatorAssociation)) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `❌ @${user} The \`/assign\` feature is only available for issues created by OWNER, MEMBER, COLLABORATOR or CONTRIBUTOR user associations. This issue was created by @${issueCreator} who has the association: ${issueCreatorAssociation}.`,
});
console.log(`Denied /assign for ${user} - Issue creator ${issueCreator} has insufficient association (${issueCreatorAssociation})`);
return;
}
console.log(`Issue creator ${issueCreator} has sufficient association (${issueCreatorAssociation}), allowing /assign for ${user}`);
}
// Log the type of user association
console.log(`Commenting user ${user} is a ${association}`);
// New contributors cannot auto assign themselves, but they can unassign themselves
if (isAssign) {
if (association === 'FIRST_TIME_CONTRIBUTOR' || association === 'FIRST_TIMER' || association === 'NONE') {
// Verify whether the commenter has merged PRs
const hasContributed = await hasMergedPRs(github, owner, repo, user);
if (!hasContributed) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `❌ @${user} First time contributors cannot use auto assignment. You have not successfully merged code into this repository. Please ask to be assigned instead.`,
});
return;
}
}
}
if (isUnassign) {
if (body.trim() !== "/unassign") {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `❌ @${user} This workflow only supports \`/unassign\` without additional text.`,
});
console.log("Invalid /unassign command (must be exact)");
return;
}
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
const currentAssignees = issue.assignees.map(a => a.login);
if (currentAssignees.includes(user)) {
console.log(`${user} is already assigned, skipping.`);
return;
}
if (currentAssignees.length > 0) {
console.log("Issue already assigned to someone else, skipping.");
return;
}
console.log(`Checking org-wide assignments for ${user}...`);
let totalAssigned = 0;
let activeRepos = [];
try {
// Fetch all repos for the org
const repos = await github.paginate(github.rest.repos.listForOrg, {
org: owner,
type: "all",
per_page: 100,
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
});
// Filter out archived repositories
activeRepos = repos.filter(r => !r.archived);
for (const r of activeRepos) {
// Exit early once limit is detected
if (totalAssigned >= maxAssignments) break;
try {
const assignedIssues = await github.paginate(
github.rest.issues.listForRepo,
{
owner,
repo: r.name,
state: "open",
assignee: user,
per_page: 100,
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
}
);
totalAssigned += assignedIssues.length;
} catch (repoError) {
console.warn(`Skipping ${r.name}: ${repoError.message}`);
// Continue with other repos
}
}
// With many repos in the org, we could hit GitHub API rate limits.
const rateLimit = await github.rest.rateLimit.get();
console.log(`Remaining rate limit: ${rateLimit.data.rate.remaining}`);
} 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
throw new Error(`Assignment failed for ${user} on issue #${issueNumber}: ${error.message || error}`);
}
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 >= maxAssignments) {
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;
}
// Re-verify the count immediately before assignment
try {
let reCheckCount = 0;
for (const r of activeRepos) {
// Exit early once limit is detected
if (reCheckCount >= maxAssignments) break;
try {
const assignedIssues = await github.paginate(
github.rest.issues.listForRepo,
{
owner,
repo: r.name,
state: "open",
assignee: user,
per_page: 100,
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
}
);
reCheckCount += assignedIssues.length;
} catch (repoError) {
console.warn(`Skipping ${r.name} during re-check: ${repoError.message}`);
}
}
if (reCheckCount >= maxAssignments) {
const message = `⚠️ @${user}, another issue was assigned to you while processing this request. You now have ${reCheckCount} open assignments. Please finish or unassign before taking new ones.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: message,
});
console.log("Limit reached after re-check, aborting assignment");
return;
}
} catch (error) {
console.error("Error during assignment re-check:", error);
// Continue with assignment since we already passed the first check
}
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
throw new Error(`Assignment failed and unable to notify user: ${error.message || error}`);
}
}