Skip to content

Commit 3fe26d3

Browse files
feat: add issue automation workflow and script
1 parent 219c683 commit 3fe26d3

File tree

2 files changed

+404
-0
lines changed

2 files changed

+404
-0
lines changed
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
module.exports = async ({ github, context, core }) => {
2+
try {
3+
const { owner, repo } = context.repo;
4+
5+
// --- Helper Functions ---
6+
7+
// Add a comment to an issue
8+
async function addComment(issueNumber, body) {
9+
await github.rest.issues.createComment({
10+
owner,
11+
repo,
12+
issue_number: issueNumber,
13+
body,
14+
});
15+
}
16+
17+
// Assign a user to an issue
18+
async function assignUser(issueNumber, username) {
19+
await github.rest.issues.addAssignees({
20+
owner,
21+
repo,
22+
issue_number: issueNumber,
23+
assignees: [username],
24+
});
25+
}
26+
27+
// Unassign a user from an issue
28+
async function unassignUser(issueNumber, username) {
29+
await github.rest.issues.removeAssignees({
30+
owner,
31+
repo,
32+
issue_number: issueNumber,
33+
assignees: [username],
34+
});
35+
}
36+
37+
38+
39+
// Check if a user has too many assigned issues
40+
async function checkUserAssignmentLimit(username) {
41+
const response = await github.rest.issues.listForRepo({
42+
owner,
43+
repo,
44+
state: 'open',
45+
assignee: username,
46+
});
47+
// Limit is 2 issues
48+
return response.data.length >= 2;
49+
}
50+
51+
async function syncIssueToProject(issueNodeId, statusName) {
52+
// 1. Find the Project details (Code-A2Z owner, Project #1)
53+
// Note: owner in context is 'Code-A2Z' based on repo url.
54+
const projectQuery = `
55+
query($org: String!, $number: Int!) {
56+
organization(login: $org) {
57+
projectV2(number: $number) {
58+
id
59+
fields(first: 20) {
60+
nodes {
61+
... on ProjectV2SingleSelectField {
62+
id
63+
name
64+
options {
65+
id
66+
name
67+
}
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
`;
75+
76+
// We assume the organization is the repo owner.
77+
// If repo is user-owned (not org), this query needs 'user(login: $org)'.
78+
// Given the URL https://github.com/orgs/Code-A2Z/projects/1/views/2, it IS an organization.
79+
80+
let projectData;
81+
try {
82+
projectData = await github.graphql(projectQuery, {
83+
org: owner,
84+
number: 1
85+
});
86+
} catch (error) {
87+
// Fallback if not an org, maybe a user? But URL says orgs.
88+
// Or maybe permissions issue.
89+
console.log("Error querying project:", error.message);
90+
return;
91+
}
92+
93+
const project = projectData.organization.projectV2;
94+
if (!project) return;
95+
96+
// 2. Add Item to Project
97+
const addMutation = `
98+
mutation($projectId: ID!, $contentId: ID!) {
99+
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
100+
item {
101+
id
102+
}
103+
}
104+
}
105+
`;
106+
107+
const addResult = await github.graphql(addMutation, {
108+
projectId: project.id,
109+
contentId: issueNodeId
110+
});
111+
112+
const itemId = addResult.addProjectV2ItemById.item.id;
113+
114+
// 3. Find Status Field and Option
115+
const statusField = project.fields.nodes.find(f => f.name === 'Status');
116+
if (!statusField) return;
117+
118+
const statusOption = statusField.options.find(o => o.name.toLowerCase() === statusName.toLowerCase());
119+
// If exact match not found, try rough match (e.g. "In Progress" vs "In-Progress")
120+
// Or default to 'Todo' if 'In Progress' missing
121+
// For now, Strict match or return
122+
if (!statusOption) {
123+
console.log(`Status option '${statusName}' not found in project.`);
124+
return;
125+
}
126+
127+
// 4. Update Field
128+
const updateMutation = `
129+
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) {
130+
updateProjectV2ItemFieldValue(
131+
input: {
132+
projectId: $projectId
133+
itemId: $itemId
134+
fieldId: $fieldId
135+
value: { singleSelectOptionId: $value }
136+
}
137+
) {
138+
projectV2Item {
139+
id
140+
}
141+
}
142+
}
143+
`;
144+
145+
await github.graphql(updateMutation, {
146+
projectId: project.id,
147+
itemId: itemId,
148+
fieldId: statusField.id,
149+
value: statusOption.id
150+
});
151+
}
152+
153+
// --- Main Logic Handlers ---
154+
155+
// Handle Issue Opened/Edited/Reopened
156+
if (['opened', 'edited', 'reopened'].includes(context.payload.action) && context.eventName === 'issues') {
157+
const issue = context.payload.issue;
158+
const body = issue.body || "";
159+
160+
// Check "Would you like to work on this issue?" checkbox
161+
// Regex looks for: "- [x] Yes" or similar variations inside the "Would you like to work on this issue?" section
162+
// Since templates can vary, we look for the specific answer pattern.
163+
// Based on provided templates, it's a dropdown "Yes" or "No".
164+
// Markdown for dropdown selection often (but not always) renders just the text or is parsed from the body directly.
165+
// In YAML issue forms, the body is sometimes just the text.
166+
// However, usually the payload body contains the full markdown.
167+
// Let's assume standard markdown format "### Would you like to work on this issue?\n\nYes"
168+
169+
const wantsToWork = /### Would you like to work on this issue\?\s*[\r\n]+\s*Yes/i.test(body);
170+
171+
if (wantsToWork) {
172+
if (issue.assignees && issue.assignees.length > 0) {
173+
console.log(`Issue #${issue.number} is already assigned. Skipping auto-assignment.`);
174+
return;
175+
}
176+
177+
const username = issue.user.login;
178+
const limitReached = await checkUserAssignmentLimit(username);
179+
180+
if (limitReached) {
181+
await addComment(issue.number, `Hey @${username}, you already have 2 or more assigned issues. Please complete them before exploring new ones.`);
182+
return;
183+
}
184+
185+
await assignUser(issue.number, username);
186+
try {
187+
await syncIssueToProject(issue.node_id, "In Progress");
188+
} catch (err) {
189+
console.log("Failed to sync to project:", err.message);
190+
}
191+
192+
await addComment(issue.number, `Hey @${username}, this issue is assigned to you! 🚀\nPlease ensure you submit a PR within the timeline.`);
193+
}
194+
}
195+
196+
// Handle Issue Comments (/assign)
197+
if (context.eventName === 'issue_comment' && context.payload.action === 'created') {
198+
const comment = context.payload.comment;
199+
const issue = context.payload.issue;
200+
const body = comment.body.trim();
201+
202+
if (body.toLowerCase().includes('/assign')) {
203+
if (issue.assignees && issue.assignees.length > 0) {
204+
const assigneeName = issue.assignees[0].login;
205+
await addComment(issue.number, `This issue is already assigned to @${assigneeName}. Please check other available issues.`);
206+
return;
207+
}
208+
209+
// Check for 'up-for-grabs' label or if it handles unlabelled issues (implied by "Work on... unlabelled issues")
210+
// Requirement: "Remove the label up-for-grabs" implies it might have it.
211+
// Requirement: "Work on newly created issues, labelled isssues, unlabelled issues..." -> broad scope.
212+
213+
const username = comment.user.login;
214+
const limitReached = await checkUserAssignmentLimit(username);
215+
216+
if (limitReached) {
217+
await addComment(issue.number, `Hey @${username}, you already have 2 or more assigned issues. Please complete them before exploring new ones.`);
218+
return;
219+
}
220+
221+
await assignUser(issue.number, username);
222+
223+
try {
224+
await syncIssueToProject(issue.node_id, "In Progress");
225+
} catch (err) {
226+
console.log("Failed to sync to project:", err.message);
227+
}
228+
229+
await addComment(issue.number, `Hey @${username}, this issue is assigned to you! 🚀`);
230+
}
231+
}
232+
233+
// Handle Scheduled Deadline Checks
234+
if (context.eventName === 'schedule') {
235+
// Fetch all open issues with 'Status: Assigned'
236+
// Using search API might be better to filter by label and state
237+
let issues = [];
238+
let fetchedFromProject = false;
239+
240+
// Try to fetch from Project first (OS - TASK TRACKER)
241+
try {
242+
// Fetch items from Code-A2Z Project #1
243+
// We map GraphQL result to match the REST API issue structure for compatibility
244+
const projectQuery = `
245+
query($org: String!, $number: Int!) {
246+
organization(login: $org) {
247+
projectV2(number: $number) {
248+
items(first: 100) {
249+
nodes {
250+
content {
251+
... on Issue {
252+
number
253+
repository { name owner { login } }
254+
assignees(first: 10) { nodes { login } }
255+
labels(first: 10) { nodes { name } }
256+
state
257+
}
258+
}
259+
}
260+
}
261+
}
262+
}
263+
}
264+
`;
265+
266+
// Explicitly query 'Code-A2Z' organization as requested
267+
const projectData = await github.graphql(projectQuery, {
268+
org: 'Code-A2Z',
269+
number: 1
270+
});
271+
272+
const nodes = projectData.organization.projectV2.items.nodes;
273+
274+
// Filter and map
275+
issues = nodes
276+
.map(n => n.content)
277+
// Must be an Issue (not DraftIssue), Open, and belong to THIS repo
278+
.filter(i => i && Object.keys(i).length > 0 && i.state === 'OPEN' && i.repository.name === repo && i.repository.owner.login === owner)
279+
.map(i => ({
280+
number: i.number,
281+
assignees: i.assignees.nodes, // [{login: ''}]
282+
labels: i.labels.nodes // [{name: ''}]
283+
}));
284+
285+
if (issues.length > 0) {
286+
console.log(`Fetched ${issues.length} issues from OS - TASK TRACKER project.`);
287+
fetchedFromProject = true;
288+
}
289+
} catch (err) {
290+
console.log("Failed to fetch from project (fallback to repo search):", err.message);
291+
}
292+
293+
// Fallback to Repo Search if Project fetch failed or returned empty
294+
if (!fetchedFromProject) {
295+
console.log("Fetching issues via repo search...");
296+
const query = `repo:${owner}/${repo} is:issue is:open assignee:*`;
297+
issues = await github.paginate(github.rest.search.issuesAndPullRequests, {
298+
q: query,
299+
});
300+
}
301+
302+
const now = new Date();
303+
304+
for (const issue of issues) {
305+
// Determine deadline
306+
let daysAllowed = 8; // Default low
307+
const labels = issue.labels.map(l => l.name ? l.name.toLowerCase() : '');
308+
309+
if (labels.includes('priority: high')) daysAllowed = 3;
310+
else if (labels.includes('priority: medium')) daysAllowed = 6;
311+
else if (labels.includes('priority: low')) daysAllowed = 8;
312+
313+
// Get assignment date
314+
// We need to check events to find when it was assigned
315+
const events = await github.paginate(github.rest.issues.listEvents, {
316+
owner,
317+
repo,
318+
issue_number: issue.number
319+
});
320+
321+
// Find the last 'assigned' event
322+
const assignedEvents = events.filter(e => e.event === 'assigned');
323+
if (assignedEvents.length === 0) continue; // Should probably not happen if status is Assigned
324+
325+
// Check each assignee individually
326+
for (const assigneeObj of issue.assignees) {
327+
const assignee = assigneeObj.login;
328+
329+
// Find the last 'assigned' event FOR THIS USER
330+
const assignedEvents = events.filter(e => e.event === 'assigned' && e.assignee && e.assignee.login === assignee);
331+
if (assignedEvents.length === 0) continue;
332+
333+
const lastAssigned = assignedEvents[assignedEvents.length - 1];
334+
const assignedDate = new Date(lastAssigned.created_at);
335+
336+
const deadline = new Date(assignedDate);
337+
deadline.setDate(assignedDate.getDate() + daysAllowed);
338+
339+
const warningDate = new Date(deadline);
340+
warningDate.setDate(deadline.getDate() - 1);
341+
342+
// Check for timeout
343+
if (now > deadline) {
344+
// Unassign THIS specific user
345+
await unassignUser(issue.number, assignee);
346+
347+
try {
348+
await syncIssueToProject(issue.node_id, "Todo"); // Move back to Todo/Available
349+
} catch (err) {
350+
console.log("Failed to sync to project:", err.message);
351+
}
352+
353+
await addComment(issue.number, `Hey @${assignee}, the deadline for this issue has passed. It has been unassigned.`);
354+
}
355+
// Check for warning (only warn once)
356+
else if (now > warningDate) {
357+
const comments = await github.rest.issues.listComments({
358+
owner,
359+
repo,
360+
issue_number: issue.number
361+
});
362+
// check if we already warned THIS user
363+
const botComments = comments.data.filter(c => c.user.type === 'Bot' && c.body.includes(`@${assignee}`) && c.body.includes('deadline is approaching'));
364+
365+
if (botComments.length === 0) {
366+
await addComment(issue.number, `Hey @${assignee}, just a friendly reminder that the deadline for this issue is approaching (approx. 24h left).`);
367+
}
368+
}
369+
}
370+
}
371+
}
372+
373+
} catch (error) {
374+
console.error(error);
375+
core.setFailed(`Action failed with error: ${error.message}`);
376+
}
377+
};

0 commit comments

Comments
 (0)