|
| 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