Skip to content

Commit 7ba9588

Browse files
committed
feat: implement automated slash commands for issue assignment, unassignment, and labeling via GitHub Actions workflow
1 parent fc6c692 commit 7ba9588

10 files changed

Lines changed: 474 additions & 3 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
async function fetchRepoLabels(github, owner, repo) {
2+
const labelMap = new Map();
3+
let page = 1;
4+
5+
while (true) {
6+
const { data: labels } = await github.rest.issues.listLabelsForRepo({
7+
owner,
8+
repo,
9+
per_page: 100,
10+
page,
11+
});
12+
13+
if (labels.length === 0) break;
14+
15+
for (const label of labels) {
16+
labelMap.set(label.name.toLowerCase(), label.name);
17+
}
18+
19+
if (labels.length < 100) break;
20+
page++;
21+
}
22+
23+
return labelMap;
24+
}
25+
26+
async function handleAddLabel({ github, context, labelArgs }) {
27+
const { owner, repo } = context.repo;
28+
const issueNumber = context.payload.issue.number;
29+
const issueState = context.payload.issue.state;
30+
const commenter = context.payload.comment.user.login;
31+
32+
if (issueState === 'closed') {
33+
await github.rest.issues.createComment({
34+
owner,
35+
repo,
36+
issue_number: issueNumber,
37+
body: `❌ Cannot add labels — this issue is already **closed**.`,
38+
});
39+
return;
40+
}
41+
42+
if (!labelArgs || labelArgs.length === 0) {
43+
await github.rest.issues.createComment({
44+
owner,
45+
repo,
46+
issue_number: issueNumber,
47+
body: `❌ @${commenter}, please provide at least one label name.\n\n**Usage:** \`/addlabel label1 label2 ...\`\n\n**Example:** \`/addlabel good-first-issue frontend\``,
48+
});
49+
return;
50+
}
51+
52+
const seen = new Set();
53+
const uniqueLabelArgs = [];
54+
for (const label of labelArgs) {
55+
const key = label.toLowerCase();
56+
if (!seen.has(key)) {
57+
seen.add(key);
58+
uniqueLabelArgs.push(label);
59+
}
60+
}
61+
62+
const repoLabels = await fetchRepoLabels(github, owner, repo);
63+
const validLabels = [];
64+
const invalidLabels = [];
65+
66+
for (const labelArg of uniqueLabelArgs) {
67+
const canonical = repoLabels.get(labelArg.toLowerCase());
68+
if (canonical) {
69+
validLabels.push(canonical);
70+
} else {
71+
invalidLabels.push(labelArg);
72+
}
73+
}
74+
75+
if (invalidLabels.length > 0) {
76+
const invalidList = invalidLabels.map((l) => `\`${l}\``).join(', ');
77+
const availableLabels = [...repoLabels.values()]
78+
.slice(0, 20)
79+
.map((l) => `\`${l}\``)
80+
.join(', ');
81+
82+
const moreLabelsNote =
83+
repoLabels.size > 20
84+
? `\n> _...and ${repoLabels.size - 20} more. Check the [Labels page](../labels) for the full list._`
85+
: '';
86+
87+
await github.rest.issues.createComment({
88+
owner,
89+
repo,
90+
issue_number: issueNumber,
91+
body: `❌ The following label(s) do not exist in this repository: ${invalidList}\n\n**Available labels:**\n${availableLabels}${moreLabelsNote}\n\n> 💡 Labels are case-insensitive. If you need a new label created, please ask a maintainer.`,
92+
});
93+
return;
94+
}
95+
96+
const existingLabels = context.payload.issue.labels.map((l) => l.name.toLowerCase());
97+
const newLabels = validLabels.filter((l) => !existingLabels.includes(l.toLowerCase()));
98+
const alreadyApplied = validLabels.filter((l) => existingLabels.includes(l.toLowerCase()));
99+
100+
if (newLabels.length === 0) {
101+
const alreadyList = alreadyApplied.map((l) => `\`${l}\``).join(', ');
102+
await github.rest.issues.createComment({
103+
owner,
104+
repo,
105+
issue_number: issueNumber,
106+
body: `ℹ️ All specified labels are already applied to this issue: ${alreadyList}`,
107+
});
108+
return;
109+
}
110+
111+
await github.rest.issues.addLabels({
112+
owner,
113+
repo,
114+
issue_number: issueNumber,
115+
labels: newLabels,
116+
});
117+
118+
const addedList = newLabels.map((l) => `\`${l}\``).join(', ');
119+
let body = `✅ Added labels: ${addedList}`;
120+
121+
if (alreadyApplied.length > 0) {
122+
const skippedList = alreadyApplied.map((l) => `\`${l}\``).join(', ');
123+
body += `\n\n> ℹ️ Already applied (skipped): ${skippedList}`;
124+
}
125+
126+
await github.rest.issues.createComment({
127+
owner,
128+
repo,
129+
issue_number: issueNumber,
130+
body,
131+
});
132+
}
133+
134+
module.exports = { handleAddLabel };
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 handleAssign({ github, context, username, hasWriteAccess }) {
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 (!hasWriteAccess) {
24+
await github.rest.issues.createComment({
25+
owner,
26+
repo,
27+
issue_number: issueNumber,
28+
body: `⛔ @${commenter}, you don't have permission to use \`/assign\`. Only maintainers and collaborators with write access can assign issues.`,
29+
});
30+
return;
31+
}
32+
33+
if (issueState === 'closed') {
34+
await github.rest.issues.createComment({
35+
owner,
36+
repo,
37+
issue_number: issueNumber,
38+
body: `❌ Cannot assign \`@${username}\` — this issue is already **closed**.`,
39+
});
40+
return;
41+
}
42+
43+
const usernameRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
44+
if (!usernameRegex.test(username)) {
45+
await github.rest.issues.createComment({
46+
owner,
47+
repo,
48+
issue_number: issueNumber,
49+
body: `❌ \`@${username}\` is not a valid GitHub username.`,
50+
});
51+
return;
52+
}
53+
54+
try {
55+
await github.rest.users.getByUsername({ username });
56+
} catch (error) {
57+
if (error.status === 404) {
58+
await github.rest.issues.createComment({
59+
owner,
60+
repo,
61+
issue_number: issueNumber,
62+
body: `❌ GitHub user \`@${username}\` does not exist. Please check the username and try again.`,
63+
});
64+
return;
65+
}
66+
throw error;
67+
}
68+
69+
const currentAssignees = context.payload.issue.assignees.map((a) => a.login.toLowerCase());
70+
if (currentAssignees.includes(username.toLowerCase())) {
71+
await github.rest.issues.createComment({
72+
owner,
73+
repo,
74+
issue_number: issueNumber,
75+
body: `ℹ️ \`@${username}\` is already assigned to this issue.`,
76+
});
77+
return;
78+
}
79+
80+
const existingIssue = await findExistingAssignment(github, owner, repo, username, issueNumber);
81+
if (existingIssue) {
82+
await github.rest.issues.createComment({
83+
owner,
84+
repo,
85+
issue_number: issueNumber,
86+
body: `❌ @${username} already has an active assigned issue.\nPlease complete or unassign the current issue first.\n\n> 📋 Active issue: [#${existingIssue.number}${existingIssue.title}](${existingIssue.html_url})`,
87+
});
88+
return;
89+
}
90+
91+
await github.rest.issues.addAssignees({
92+
owner,
93+
repo,
94+
issue_number: issueNumber,
95+
assignees: [username],
96+
});
97+
98+
await github.rest.issues.createComment({
99+
owner,
100+
repo,
101+
issue_number: issueNumber,
102+
body: `✅ Successfully assigned issue to @${username}\n\n> 💡 Please read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) if you haven't already. Good luck! 🚀`,
103+
});
104+
}
105+
106+
module.exports = { handleAssign };
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const { parseCommand } = require('./parse-command');
2+
const { hasWriteAccess } = require('./permissions');
3+
const { handleAssign } = require('./assign-handler');
4+
const { handleUnassign } = require('./unassign-handler');
5+
const { handleAddLabel } = require('./addlabel-handler');
6+
7+
module.exports = async ({ github, context, core }) => {
8+
const commentBody = context.payload.comment?.body;
9+
const commenter = context.payload.comment?.user?.login;
10+
const { owner, repo } = context.repo;
11+
const issueNumber = context.payload.issue?.number;
12+
13+
if (!context.payload.issue) return;
14+
if (context.payload.comment?.user?.type === 'Bot') return;
15+
16+
const parsed = parseCommand(commentBody);
17+
if (!parsed) return;
18+
19+
let writerAccess = null;
20+
const writeRestrictedCommands = ['assign', 'unassign'];
21+
22+
if (writeRestrictedCommands.includes(parsed.command)) {
23+
writerAccess = await hasWriteAccess(github, owner, repo, commenter);
24+
}
25+
26+
try {
27+
switch (parsed.command) {
28+
case 'assign':
29+
await handleAssign({
30+
github,
31+
context,
32+
username: parsed.username,
33+
hasWriteAccess: writerAccess,
34+
});
35+
break;
36+
case 'unassign':
37+
await handleUnassign({
38+
github,
39+
context,
40+
username: parsed.username,
41+
hasWriteAccess: writerAccess,
42+
});
43+
break;
44+
case 'addlabel':
45+
await handleAddLabel({ github, context, labelArgs: parsed.labels });
46+
break;
47+
}
48+
} catch (error) {
49+
core.error(`Error processing command /${parsed.command}: ${error.message}`);
50+
try {
51+
await github.rest.issues.createComment({
52+
owner,
53+
repo,
54+
issue_number: issueNumber,
55+
body: `⚠️ An unexpected error occurred while processing your command. Please try again or contact a maintainer.\n\n> \`${error.message}\``,
56+
});
57+
} catch (_) {}
58+
core.setFailed(error.message);
59+
}
60+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
function parseCommand(commentBody) {
2+
if (!commentBody || typeof commentBody !== 'string') return null;
3+
4+
const lines = commentBody.split('\n');
5+
6+
for (const rawLine of lines) {
7+
const line = rawLine.trim();
8+
9+
const assignMatch = line.match(/^\/assign\s+@([^\s]+)\s*$/i);
10+
if (assignMatch) return { command: 'assign', username: assignMatch[1] };
11+
12+
const unassignMatch = line.match(/^\/unassign\s+@([^\s]+)\s*$/i);
13+
if (unassignMatch) return { command: 'unassign', username: unassignMatch[1] };
14+
15+
const addlabelMatch = line.match(/^\/addlabel\s+(.+)$/i);
16+
if (addlabelMatch) {
17+
const labels = parseLabels(addlabelMatch[1]);
18+
if (labels.length > 0) return { command: 'addlabel', labels };
19+
return { command: 'addlabel', labels: [] };
20+
}
21+
22+
if (/^\/addlabel\s*$/i.test(line)) {
23+
return { command: 'addlabel', labels: [] };
24+
}
25+
}
26+
return null;
27+
}
28+
29+
function parseLabels(raw) {
30+
const labels = [];
31+
const tokenRegex = /"([^"]+)"|'([^']+)'|(\S+)/g;
32+
let match;
33+
34+
while ((match = tokenRegex.exec(raw)) !== null) {
35+
const label = (match[1] || match[2] || match[3]).trim();
36+
if (label) labels.push(label);
37+
}
38+
return labels;
39+
}
40+
41+
module.exports = { parseCommand, parseLabels };
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
async function hasWriteAccess(github, owner, repo, username) {
2+
try {
3+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
4+
owner,
5+
repo,
6+
username,
7+
});
8+
return data.permission === 'admin' || data.permission === 'write';
9+
} catch (error) {
10+
if (error.status === 404 || error.status === 403) return false;
11+
throw error;
12+
}
13+
}
14+
15+
module.exports = { hasWriteAccess };

0 commit comments

Comments
 (0)