1- name : auto_assign_issue
1+ name : Auto Assign Issue
22
33on :
44 issue_comment :
55 types : [created]
66
77permissions :
88 issues : write
9- pull-requests : read
9+ pull-requests : write
1010 contents : read
1111
1212jobs :
1313 assign :
14- if : github.event.issue.pull_request == null
14+ # Ensure this only runs on actual Issues, not Pull Request comments
15+ if : ${{ !github.event.issue.pull_request }}
1516 runs-on : ubuntu-latest
1617
1718 steps :
@@ -23,196 +24,55 @@ jobs:
2324 const repo = context.repo.repo;
2425 const issue_number = context.payload.issue.number;
2526 const assignee = context.payload.comment.user.login;
26-
2727 const comment_id = context.payload.comment.id;
28-
2928 const body = (context.payload.comment.body || '').toLowerCase();
3029
31- // keyword check for auto-assigning
32- const hasAssignKeyword =
33- body.includes('assign me') ||
34- body.includes('assign it to me') ||
35- body.includes('assign this to me') ||
36- body.includes('assign this issue to me') ||
37- body.includes('assign the issue to me');
30+ const keywords = ['assign me', 'assign it to me', 'assign this to me', 'assign this issue to me'];
31+ const hasAssignKeyword = keywords.some(k => body.includes(k));
3832
39- if (!hasAssignKeyword) {
40- core.info('No assign keyword found.');
41- return;
42- }
33+ if (!hasAssignKeyword) return;
4334
44- // Helper: react to the triggering comment
45- async function react(content) {
46- try {
47- await github.rest.reactions.createForIssueComment({
48- owner,
49- repo,
50- comment_id,
51- content, // "+1", "confused", "rocket", etc.
52- });
53- } catch (e) {
54- // If reactions aren't permitted for some reason, don't fail the whole workflow.
55- core.info(`Could not add reaction (${content}): ${e.message}`);
56- }
57- }
58-
59- // 1) Don't reassign if already assigned
60- const current = context.payload.issue.assignees?.map(a => a.login) || [];
61- if (current.length > 0) {
62- core.info(`Already assigned to: ${current.join(', ')}`);
63- await react('confused');
35+ // 1. Check if already assigned
36+ const currentAssignees = context.payload.issue.assignees || [];
37+ if (currentAssignees.length > 0) {
38+ core.info('Already assigned.');
6439 return;
6540 }
6641
67- // Helper: format issue as a single markdown link line (prevents duplicate link/title rendering)
68- function formatIssueLine(i) {
69- return `- [#${i.number} — ${i.title}](${i.url})`;
70- }
71-
72- // Helper: suggest other unblocked issues
73- async function getSuggestions(limit = 5) {
74- // open issues, unassigned, not blocked
75- const q = `repo:${owner}/${repo} is:issue is:open no:assignee -label:blocked`;
76- const res = await github.rest.search.issuesAndPullRequests({ q, per_page: limit });
77- return (res.data.items || []).map(i => ({
78- number: i.number,
79- title: i.title,
80- url: i.html_url,
81- }));
82- }
83-
84- // 2) Block if label "blocked" exists (and suggest alternatives)
85- const labels = (context.payload.issue.labels || [])
86- .map(l => (typeof l === 'string' ? l : l.name))
87- .filter(Boolean)
88- .map(l => l.toLowerCase());
89-
90- if (labels.includes('blocked')) {
91- const suggestions = await getSuggestions(5);
92- const suggestionText = suggestions.length
93- ? suggestions.map(formatIssueLine).join('\n')
94- : '_No unblocked, unassigned issues found right now._';
95-
96- await github.rest.issues.createComment({
97- owner, repo, issue_number,
98- body: [
99- `⛔ Sorry @${assignee} — this issue is currently **blocked** and can’t be claimed yet.`,
100- ``,
101- `Here are some other **unblocked** issues you can pick up instead:`,
102- suggestionText,
103- ].join('\n'),
42+ try {
43+ // 2. Attempt Assignment
44+ await github.rest.issues.addAssignees({
45+ owner,
46+ repo,
47+ issue_number,
48+ assignees: [assignee],
10449 });
10550
106- await react('confused');
107- return;
108- }
109-
110- // 3) Capacity check:
111- // If the candidate has 2 assigned issues that do NOT have PRs, then do not assign a new issue.
112- //
113- // "Linked" here means: a PR cross-references the issue (GitHub timeline cross-reference),
114- // and the PR is authored by the candidate.
115- async function hasLinkedPRForIssue(issueNum) {
116- const query = `
117- query($owner: String!, $repo: String!, $number: Int!) {
118- repository(owner: $owner, name: $repo) {
119- issueOrPullRequest(number: $number) {
120- __typename
121- ... on Issue {
122- timelineItems(last: 50, itemTypes: [CROSS_REFERENCED_EVENT]) {
123- nodes {
124- __typename
125- ... on CrossReferencedEvent {
126- source {
127- __typename
128- ... on PullRequest {
129- number
130- url
131- state
132- isDraft
133- author { login }
134- }
135- }
136- }
137- }
138- }
139- }
140- }
141- }
142- }
143- `;
144-
145- const data = await github.graphql(query, { owner, repo, number: issueNum });
146-
147- const iop = data?.repository?.issueOrPullRequest;
148- if (!iop || iop.__typename !== 'Issue') return false;
149-
150- const nodes = iop.timelineItems?.nodes || [];
151- for (const n of nodes) {
152- if (n?.__typename !== 'CrossReferencedEvent') continue;
153- const pr = n?.source;
154- if (pr?.__typename !== 'PullRequest') continue;
51+ // 3. Verify if assignment actually stuck
52+ const { data: updatedIssue } = await github.rest.issues.get({
53+ owner, repo, issue_number
54+ });
55+
56+ const isAssigned = updatedIssue.assignees.some(a => a.login === assignee);
15557
156- if ((pr?.author?.login || '').toLowerCase() === assignee.toLowerCase()) {
157- // Counts any PR state (OPEN/MERGED/CLOSED) as "has a PR for it".
158- return true;
159- }
58+ if (!isAssigned) {
59+ await github.rest.issues.createComment({
60+ owner, repo, issue_number,
61+ body: `⚠️ @${assignee}, I couldn't assign you. You must be a collaborator or a member of this organization to be assigned to issues.`
62+ });
63+ return;
16064 }
161- return false;
162- }
163-
164- async function getOpenAssignedIssuesForUser() {
165- // Find open issues assigned to this user in this repo
166- const q = `repo:${owner}/${repo} is:issue is:open assignee:${assignee}`;
167- const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 20 });
168- return (res.data.items || []).map(i => ({
169- number: i.number,
170- title: i.title,
171- url: i.html_url,
172- }));
173- }
174-
175- const assignedIssues = await getOpenAssignedIssuesForUser();
176-
177- // Count assigned issues that do NOT have PRs by this assignee
178- const issuesWithoutPR = [];
179- for (const it of assignedIssues) {
180- const hasPR = await hasLinkedPRForIssue(it.number);
181- if (!hasPR) issuesWithoutPR.push(it);
182- if (issuesWithoutPR.length >= 2) break; // early exit
183- }
184-
185- if (issuesWithoutPR.length >= 2) {
186- const noPRText = issuesWithoutPR
187- .slice(0, 10)
188- .map(formatIssueLine)
189- .join('\n');
19065
66+ // 4. Success Actions
19167 await github.rest.issues.createComment({
19268 owner, repo, issue_number,
193- body: [
194- `⛔ Sorry @${assignee} — you already have **2 or more** assigned issues with **no pull request(s)**.`,
195- ``,
196- `Please open a PR for one of your assigned issues before claiming a new one:`,
197- noPRText || '_Could not list the issues (unexpected)._',
198- ``,
199- `Tip: opening a **draft PR** is fine—once a PR is linked, you'll be able to claim another issue.`,
200- ].join('\n'),
69+ body: `🎉 Issue assigned to @${assignee}! Happy coding!`
70+ });
71+
72+ await github.rest.reactions.createForIssueComment({
73+ owner, repo, comment_id, content: 'rocket'
20174 });
20275
203- await react('confused');
204- return ;
76+ } catch (error) {
77+ core.setFailed(`Workflow failed: ${error.message}`) ;
20578 }
206-
207- // 4) Assign
208- await github.rest.issues.addAssignees({
209- owner, repo, issue_number,
210- assignees: [assignee],
211- });
212-
213- await github.rest.issues.createComment({
214- owner, repo, issue_number,
215- body: `🎉 Thank you for your interest in contributing!\n\nThe issue has been assigned to @${assignee}. Happy coding!`,
216- });
217-
218- await react('rocket');
0 commit comments