Skip to content

Commit 96e3f96

Browse files
authored
Added auto-assign github workflow (#89)
* Added auto-assign github workflow * Minor update to auto-assign.yml * Minor update to auto-assign.yml * Minor update to auto-assign.yml * Minor update to auto-assign.yml * Minor update to auto-assign.yml
1 parent bafc6ce commit 96e3f96

2 files changed

Lines changed: 140 additions & 22 deletions

File tree

.github/workflows/auto-assign.yml

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ on:
44
issue_comment:
55
types: [created]
66

7+
env:
8+
MAX_OPEN_ASSIGNMENTS: 2
9+
710
jobs:
811
handle-comment:
912
runs-on: ubuntu-latest
13+
timeout-minutes: 15
1014
permissions:
1115
issues: write
1216
pull-requests: write
@@ -15,16 +19,54 @@ jobs:
1519
- name: Handle /assign or /unassign
1620
uses: actions/github-script@v7
1721
with:
22+
# ORG_ACCESS_TOKEN must have the following scopes:
23+
# - repo (or public_repo if using public repos only)
24+
# - org:read (to enumerate organization repos)
1825
github-token: ${{ secrets.ORG_ACCESS_TOKEN }}
1926
script: |
27+
/**
28+
* Auto-assign workflow for organization-wide issue assignment limits
29+
*
30+
* Features:
31+
* - Self-assignment only: Users comment /assign to assign themselves
32+
* - Organization-wide limit: Enforces MAX_OPEN_ASSIGNMENTS across all org repos
33+
* - Race condition mitigation: Re-checks assignment count before final assignment
34+
*
35+
* Required GitHub Token Permissions (ORG_ACCESS_TOKEN):
36+
* - repo (or public_repo for public repos only)
37+
* - org:read (to enumerate organization repositories)
38+
*
39+
* Configuration:
40+
* - MAX_OPEN_ASSIGNMENTS: Maximum open issues per user (default: 2)
41+
*
42+
* Commands:
43+
* - /assign: Self-assign an issue (if under limit)
44+
* - /unassign: Remove self-assignment from an issue
45+
*
46+
* Limitations:
47+
* - Small race condition window exists between checks and assignment
48+
* - Only self-assignment supported (cannot assign others)
49+
* - Requires archived repos to be properly marked in GitHub
50+
*/
51+
2052
const comment = context.payload.comment;
21-
const body = comment.body.trim();
53+
const body = (comment.body || "").trim();
2254
const user = comment.user.login;
2355
const association = context.payload.comment.author_association;
2456
const issue = context.payload.issue || context.payload.pull_request;
2557
const issueNumber = issue.number;
2658
const { owner, repo } = context.repo;
2759
60+
// Default assignment limit is 2 if not configured
61+
const maxAssignments = parseInt(process.env.MAX_OPEN_ASSIGNMENTS || '2');
62+
63+
// New contributors get only 1 assignment
64+
let userMaxAssignments = maxAssignments;
65+
if (association === 'FIRST_TIME_CONTRIBUTOR' || association === 'FIRST_TIMER') {
66+
userMaxAssignments = 1;
67+
console.log(`User ${user} is a first-time contributor, limiting to 1 assignment`);
68+
}
69+
2870
// Ignore bots only
2971
if (comment.user.type === "Bot") {
3072
console.log("Ignoring bot");
@@ -39,12 +81,23 @@ jobs:
3981
return;
4082
}
4183
84+
// After checking isAssign
85+
if (isAssign && body.trim() !== "/assign") {
86+
await github.rest.issues.createComment({
87+
owner,
88+
repo,
89+
issue_number: issueNumber,
90+
body: `❌ @${user} This workflow only supports self-assignment. Use \`/assign\` without specifying a username.`,
91+
});
92+
return;
93+
}
94+
4295
if (isUnassign) {
4396
const currentAssignees = issue.assignees.map(a => a.login);
4497
if (!currentAssignees.includes(user)) {
4598
console.log(`${user} is not assigned, skipping unassign`);
4699
return;
47-
}
100+
}
48101
49102
try {
50103
await github.rest.issues.removeAssignees({
@@ -81,35 +134,59 @@ jobs:
81134
}
82135
83136
// For /assign
84-
if (issue.assignees.length > 0) {
85-
console.log("Already assigned, skipping.");
137+
const currentAssignees = issue.assignees.map(a => a.login);
138+
if (currentAssignees.includes(user)) {
139+
console.log(`${user} is already assigned, skipping.`);
86140
return;
87141
}
142+
if (currentAssignees.length > 0) {
143+
console.log("Issue already assigned to someone else, skipping.");
144+
return;
145+
}
88146
89147
console.log(`Checking org-wide assignments for ${user}...`);
90148
let totalAssigned = 0;
149+
let activeRepos = [];
91150
92151
try {
93152
// Fetch all repos for the org
94153
const repos = await github.paginate(github.rest.repos.listForOrg, {
95154
org: owner,
96155
type: "all",
97156
per_page: 100,
157+
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
98158
});
99159
100-
for (const r of repos) {
101-
const assignedIssues = await github.paginate(
102-
github.rest.issues.listForRepo,
103-
{
104-
owner,
105-
repo: r.name,
106-
state: "open",
107-
assignee: user,
108-
per_page: 100,
109-
}
110-
);
111-
totalAssigned += assignedIssues.length;
160+
// Filter out archived repositories
161+
activeRepos = repos.filter(r => !r.archived);
162+
for (const r of activeRepos) {
163+
164+
// Exit early once limit is detected
165+
if (totalAssigned >= userMaxAssignments) break;
166+
167+
try {
168+
const assignedIssues = await github.paginate(
169+
github.rest.issues.listForRepo,
170+
{
171+
owner,
172+
repo: r.name,
173+
state: "open",
174+
assignee: user,
175+
per_page: 100,
176+
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
177+
}
178+
);
179+
totalAssigned += assignedIssues.length;
180+
} catch (repoError) {
181+
console.warn(`Skipping ${r.name}: ${repoError.message}`);
182+
// Continue with other repos
183+
}
112184
}
185+
186+
// With many repos in the org, we could hit GitHub API rate limits.
187+
const rateLimit = await github.rest.rateLimit.get();
188+
console.log(`Remaining rate limit: ${rateLimit.data.rate.remaining}`);
189+
113190
} catch (error) {
114191
console.error("Error fetching org-wide assignments:", error);
115192
@@ -129,8 +206,7 @@ jobs:
129206
}
130207
131208
// Mark the workflow run as failed
132-
core.setFailed(`Org-wide assignment check failed: ${error.message || error}`);
133-
return;
209+
throw new Error(`Assignment failed for ${user} on issue #${issueNumber}: ${error.message || error}`);
134210
}
135211
136212
console.log(`${user} currently has ${totalAssigned} open issues assigned across ${owner}`);
@@ -151,7 +227,7 @@ jobs:
151227
// if over limit (but this creates noise in notifications).
152228
//
153229
// Current implementation: Option 1 (accept transient violations for simplicity).
154-
if (totalAssigned >= 2) {
230+
if (totalAssigned >= userMaxAssignments) {
155231
const message = `⚠️ @${user} already has ${totalAssigned} open assignments across the org. Please finish or unassign before taking new ones.`;
156232
await github.rest.issues.createComment({
157233
owner,
@@ -163,6 +239,48 @@ jobs:
163239
return;
164240
}
165241
242+
// Re-verify the count immediately before assignment
243+
try {
244+
let reCheckCount = 0;
245+
for (const r of activeRepos) {
246+
247+
// Exit early once limit is detected
248+
if (totalAssigned >= userMaxAssignments) break;
249+
250+
try {
251+
const assignedIssues = await github.paginate(
252+
github.rest.issues.listForRepo,
253+
{
254+
owner,
255+
repo: r.name,
256+
state: "open",
257+
assignee: user,
258+
per_page: 100,
259+
max_items: 500, // Limit to first 500 repos to avoid excessive API calls
260+
}
261+
);
262+
reCheckCount += assignedIssues.length;
263+
} catch (repoError) {
264+
console.warn(`Skipping ${r.name} during re-check: ${repoError.message}`);
265+
}
266+
}
267+
268+
if (reCheckCount >= userMaxAssignments) {
269+
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.`;
270+
await github.rest.issues.createComment({
271+
owner,
272+
repo,
273+
issue_number: issueNumber,
274+
body: message,
275+
});
276+
console.log("Limit reached after re-check, aborting assignment");
277+
return;
278+
}
279+
} catch (error) {
280+
console.error("Error during assignment re-check:", error);
281+
// Continue with assignment since we already passed the first check
282+
}
283+
166284
try {
167285
await github.rest.issues.addAssignees({
168286
owner,
@@ -196,6 +314,6 @@ jobs:
196314
} catch (commentError) {
197315
console.error(`Failed to post error comment for user ${user} on issue #${issueNumber}:`, commentError);
198316
// Fall back to marking the workflow as failed if we can't even comment
199-
core.setFailed(`Assignment failed for ${user} on issue #${issueNumber}: ${error.message || error}`);
317+
throw new Error(`Assignment failed and unable to notify user: ${error.message || error}`);
200318
}
201319
}

docusaurus.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,10 @@ const config: Config = {
176176
copyright: `Copyright © ${new Date().getFullYear()} The Palisadoes Foundation, LLC. Built with Docusaurus.`,
177177
},
178178
colorMode: {
179-
defaultMode: "light",
179+
defaultMode: 'light',
180180
disableSwitch: false,
181181
respectPrefersColorScheme: true,
182-
},
182+
},
183183
prism: {
184184
theme: prismThemes.github,
185185
darkTheme: prismThemes.dracula,

0 commit comments

Comments
 (0)