Skip to content

Commit 8c3fef2

Browse files
committed
feat: implement automated issue claiming, assignment limits, and stale assignment cleanup via GitHub Actions
1 parent 7ba9588 commit 8c3fef2

10 files changed

Lines changed: 256 additions & 3 deletions

File tree

.github/scripts/issue-management/assign-handler.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function handleAssign({ github, context, username, hasWriteAccess }) {
3535
owner,
3636
repo,
3737
issue_number: issueNumber,
38-
body: `❌ Cannot assign \`@${username}\` — this issue is already **closed**.`,
38+
body: `❌ Commands cannot be used on closed issues.`,
3939
});
4040
return;
4141
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
async function findExistingAssignment(github, owner, repo, username, currentIssueNumber) {
2+
const { data: issues } = await github.rest.issues.listForRepo({
3+
owner,
4+
repo,
5+
assignee: username,
6+
state: 'open',
7+
per_page: 100,
8+
});
9+
10+
const assignedIssues = issues.filter(
11+
(issue) => !issue.pull_request && issue.number !== currentIssueNumber
12+
);
13+
14+
return assignedIssues.length > 0 ? assignedIssues[0] : null;
15+
}
16+
17+
async function handleClaim({ github, context }) {
18+
const { owner, repo } = context.repo;
19+
const issueNumber = context.payload.issue.number;
20+
const issueState = context.payload.issue.state;
21+
const commenter = context.payload.comment.user.login;
22+
23+
if (issueState === 'closed') {
24+
await github.rest.issues.createComment({
25+
owner,
26+
repo,
27+
issue_number: issueNumber,
28+
body: `❌ Commands cannot be used on closed issues.`,
29+
});
30+
return;
31+
}
32+
33+
const currentAssignees = context.payload.issue.assignees.map((a) => a.login.toLowerCase());
34+
35+
if (currentAssignees.length > 0) {
36+
if (currentAssignees.includes(commenter.toLowerCase())) {
37+
await github.rest.issues.createComment({
38+
owner,
39+
repo,
40+
issue_number: issueNumber,
41+
body: `ℹ️ You are already assigned to this issue.`,
42+
});
43+
return;
44+
}
45+
const assigneeList = currentAssignees.map((a) => `@${a}`).join(', ');
46+
await github.rest.issues.createComment({
47+
owner,
48+
repo,
49+
issue_number: issueNumber,
50+
body: `❌ This issue is already assigned to ${assigneeList}`,
51+
});
52+
return;
53+
}
54+
55+
const existingIssue = await findExistingAssignment(github, owner, repo, commenter, issueNumber);
56+
if (existingIssue) {
57+
await github.rest.issues.createComment({
58+
owner,
59+
repo,
60+
issue_number: issueNumber,
61+
body: `❌ You already have an active assigned issue.\nPlease complete or unassign your current issue first.\n\n> 📋 Active issue: [#${existingIssue.number}${existingIssue.title}](${existingIssue.html_url})`,
62+
});
63+
return;
64+
}
65+
66+
await github.rest.issues.addAssignees({
67+
owner,
68+
repo,
69+
issue_number: issueNumber,
70+
assignees: [commenter],
71+
});
72+
73+
await github.rest.issues.createComment({
74+
owner,
75+
repo,
76+
issue_number: issueNumber,
77+
body: `✅ Successfully assigned issue to @${commenter}\n\n> 💡 Please read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) if you haven't already. Good luck! 🚀`,
78+
});
79+
}
80+
81+
module.exports = { handleClaim };

.github/scripts/issue-management/main.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const { hasWriteAccess } = require('./permissions');
33
const { handleAssign } = require('./assign-handler');
44
const { handleUnassign } = require('./unassign-handler');
55
const { handleAddLabel } = require('./addlabel-handler');
6+
const { handleClaim } = require('./claim-handler');
67

78
module.exports = async ({ github, context, core }) => {
89
const commentBody = context.payload.comment?.body;
@@ -44,6 +45,9 @@ module.exports = async ({ github, context, core }) => {
4445
case 'addlabel':
4546
await handleAddLabel({ github, context, labelArgs: parsed.labels });
4647
break;
48+
case 'claim':
49+
await handleClaim({ github, context });
50+
break;
4751
}
4852
} catch (error) {
4953
core.error(`Error processing command /${parsed.command}: ${error.message}`);

.github/scripts/issue-management/parse-command.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ function parseCommand(commentBody) {
1212
const unassignMatch = line.match(/^\/unassign\s+@([^\s]+)\s*$/i);
1313
if (unassignMatch) return { command: 'unassign', username: unassignMatch[1] };
1414

15+
const claimMatch = line.match(/^\/claim\s*$/i);
16+
if (claimMatch) return { command: 'claim' };
17+
1518
const addlabelMatch = line.match(/^\/addlabel\s+(.+)$/i);
1619
if (addlabelMatch) {
1720
const labels = parseLabels(addlabelMatch[1]);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
async function handleStaleAssignments({ github, context, core }) {
2+
const { owner, repo } = context.repo;
3+
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
4+
const now = new Date();
5+
6+
console.log(`Starting stale assignment check for ${owner}/${repo}`);
7+
8+
let page = 1;
9+
let staleCount = 0;
10+
11+
while (true) {
12+
const { data: issues } = await github.rest.issues.listForRepo({
13+
owner,
14+
repo,
15+
state: 'open',
16+
assignee: '*', // Only fetch issues that have at least one assignee
17+
per_page: 100,
18+
page,
19+
});
20+
21+
if (issues.length === 0) break;
22+
23+
for (const issue of issues) {
24+
// GitHub API returns PRs as issues, we only want actual issues
25+
if (issue.pull_request) continue;
26+
27+
const updatedAt = new Date(issue.updated_at);
28+
const timeSinceUpdate = now.getTime() - updatedAt.getTime();
29+
30+
if (timeSinceUpdate > THREE_DAYS_MS) {
31+
console.log(
32+
`Issue #${issue.number} has been inactive since ${issue.updated_at}. Removing assignees.`
33+
);
34+
35+
// 1. Remove all assignees
36+
const currentAssignees = issue.assignees.map((a) => a.login);
37+
if (currentAssignees.length > 0) {
38+
await github.rest.issues.removeAssignees({
39+
owner,
40+
repo,
41+
issue_number: issue.number,
42+
assignees: currentAssignees,
43+
});
44+
45+
// 2. Post a comment
46+
await github.rest.issues.createComment({
47+
owner,
48+
repo,
49+
issue_number: issue.number,
50+
body: `⚠️ Assignment automatically removed due to inactivity.\nFeel free to reclaim the issue if you want to continue working on it.`,
51+
});
52+
53+
staleCount++;
54+
}
55+
}
56+
}
57+
58+
if (issues.length < 100) break;
59+
page++;
60+
}
61+
62+
console.log(`Finished checking. Removed assignments from ${staleCount} stale issues.`);
63+
}
64+
65+
module.exports = handleStaleAssignments;

.github/scripts/issue-management/unassign-handler.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ async function handleUnassign({ github, context, username, hasWriteAccess }) {
22
const { owner, repo } = context.repo;
33
const issueNumber = context.payload.issue.number;
44
const commenter = context.payload.comment.user.login;
5+
const issueState = context.payload.issue.state;
6+
7+
if (issueState === 'closed') {
8+
await github.rest.issues.createComment({
9+
owner,
10+
repo,
11+
issue_number: issueNumber,
12+
body: `❌ Commands cannot be used on closed issues.`,
13+
});
14+
return;
15+
}
516

617
if (!hasWriteAccess) {
718
await github.rest.issues.createComment({

.github/workflows/issue-management.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ jobs:
2121
(
2222
contains(github.event.comment.body, '/assign') ||
2323
contains(github.event.comment.body, '/unassign') ||
24-
contains(github.event.comment.body, '/addlabel')
24+
contains(github.event.comment.body, '/addlabel') ||
25+
contains(github.event.comment.body, '/claim')
2526
)
2627
steps:
2728
- name: Checkout Repository
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Stale Assignment Expiry
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 * * *' # Runs daily at midnight UTC
6+
workflow_dispatch: # Allows manual triggering from the Actions tab
7+
8+
permissions:
9+
issues: write
10+
contents: read
11+
12+
jobs:
13+
remove-stale-assignments:
14+
name: Remove Stale Assignments
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout Repository
18+
uses: actions/checkout@v4
19+
with:
20+
sparse-checkout: .github/scripts
21+
sparse-checkout-cone-mode: false
22+
23+
- name: Process Stale Assignments
24+
uses: actions/github-script@v7
25+
with:
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
script: |
28+
const script = require('./.github/scripts/issue-management/stale-assignment.js');
29+
await script({ github, context, core });

CONTRIBUTING.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- [The Standard We Hold](#-the-standard-we-hold)
1111
- [Local Setup](#-local-setup)
1212
- [What to Contribute](#-what-to-contribute)
13+
- [Automated Issue Management & Claiming](#-automated-issue-management--claiming)
1314
- [Branch & Commit Conventions](#-branch--commit-conventions)
1415
- [Opening a Pull Request](#-opening-a-pull-request)
1516
- [Code Style & Quality Gates](#-code-style--quality-gates)
@@ -144,12 +145,56 @@ The accuracy engine lives in `utils/time.ts` and `lib/calculate.ts`.
144145

145146
**Rules for logic changes:**
146147

147-
- All changes must be backward-compatible (no breaking the default behavior)
148+
- All logic changes must be backward-compatible (no breaking the default behavior)
148149
- Include a code comment explaining _why_ the logic works, not just _what_ it does
149150
- If you add a new URL parameter, document it in `README.md`'s parameter table
150151

151152
---
152153

154+
## 🤖 Automated Issue Management & Claiming
155+
156+
CommitPulse uses a custom, lightweight **GitHub Actions** automation system to manage issues fairly. This ensures that everyone (especially during events like **GSSoC**) gets a chance to contribute and prevents "issue hoarding".
157+
158+
> [!IMPORTANT]
159+
> **The Golden Rule:** You can only be assigned to **ONE** open issue at a time. Finish it or unassign yourself before claiming another.
160+
161+
### 🎮 Available Commands
162+
163+
Our automation runs entirely through issue comments. Here is how you interact with it:
164+
165+
| Command | Who Can Use It? | What It Does |
166+
|---------|-----------------|--------------|
167+
| `/claim` | **Anyone** | Self-assigns the issue to you. |
168+
| `/addlabel <label1> <label2>` | **Anyone** | Adds labels to the issue (e.g. `/addlabel frontend bug`). |
169+
| `/unassign @username` | **Maintainers Only** | Removes the assignee from an issue. |
170+
| `/assign @username` | **Maintainers Only** | Manually assigns someone to an issue. |
171+
172+
### ⏳ The Inactivity Policy (Assignment Expiry)
173+
174+
To keep the project moving, assignments are not permanent.
175+
- **The 3-Day Rule:** If an issue has an assignee but sees **no activity for 3 days**, our automated background job will remove the assignment.
176+
- **What counts as activity?** Posting a comment, opening a linked PR, or a maintainer adding a label.
177+
- **Why?** It frees up stale issues so other active contributors can pick them up. If your issue expires, you can always `/claim` it again if it's still available!
178+
179+
### 💡 GSSoC Contributor Flow
180+
1. Find an unassigned open issue you want to work on.
181+
2. Comment `/claim` to lock it in.
182+
3. Need labels? Comment `/addlabel good-first-issue` (labels must already exist in the repo).
183+
4. Work on your code and submit a PR within 3 days to avoid expiry.
184+
5. Once your PR is merged and the issue is closed, you can `/claim` your next one!
185+
186+
### 🆘 Troubleshooting & Edge Cases
187+
188+
If the bot rejects your command, check these common scenarios:
189+
190+
- **"Commands cannot be used on closed issues"**: You cannot claim, assign, or unassign on closed issues. Find an open one!
191+
- **"You already have an active assigned issue"**: You must finish your current task. If you're stuck, ask a maintainer to `/unassign` you from the old one.
192+
- **"This issue is already assigned to @username"**: Be faster next time! Look for issues without assignees.
193+
- **"The following label(s) do not exist"**: You can only add existing repo labels. The bot will reply with a list of valid labels you can use.
194+
- **"You don't have permission"**: You tried to use `/assign` or `/unassign`. Please use `/claim` instead.
195+
196+
---
197+
153198
## 🌿 Branch & Commit Conventions
154199

155200
### Branch Naming

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,20 @@ Set the `GITHUB_PAT` environment variable in your Vercel project settings, and y
234234

235235
---
236236

237+
## 🤖 Automated Contributor Workflow
238+
239+
CommitPulse features a fully custom, GitHub Actions-powered **Issue Management System** designed for large open-source events like GSSoC.
240+
241+
We built an anti-hoarding, self-service automation layer right into the repository:
242+
- **Self-Claiming:** Contributors can grab issues instantly by commenting `/claim`.
243+
- **Fair Play:** A strict one-active-issue-per-contributor rule prevents issue hoarding.
244+
- **Stale Expiry:** A scheduled chron job automatically unassigns inactive contributors after 3 days.
245+
- **Self-Service Labels:** Anyone can tag issues using `/addlabel <tag>`.
246+
247+
This ensures maintainers aren't bottlenecks and the community moves incredibly fast.
248+
249+
---
250+
237251
## 🤝 Contributing
238252

239253
CommitPulse is an open project built for the Web3 and open-source community. Whether you want to design a new theme, refine the isometric geometry, or improve timezone edge cases — you are welcome here.

0 commit comments

Comments
 (0)