diff --git a/action.js b/action.js index 7478ad6..1a47111 100644 --- a/action.js +++ b/action.js @@ -51,6 +51,33 @@ function getArrayFromInput(input) { : []; } +/** + * Return array of project GIDs configured to prevent auto-closing subtasks. + */ +function getNoAutocloseProjectList() { + return getArrayFromInput(core.getInput('NO_AUTOCLOSE_PROJECTS') || ''); +} + +/** + * Check whether a task belongs to any project in NO_AUTOCLOSE_PROJECTS. + * Returns true if the task is in one of those projects, false otherwise. + */ +async function isTaskInNoAutocloseProjects(client, taskId) { + const noAutocloseList = getNoAutocloseProjectList(); + if (!noAutocloseList || noAutocloseList.length === 0) return false; + + try { + const resp = await client.tasks.getTask(taskId, { opt_fields: 'memberships.project.gid' }); + const memberships = (resp && resp.data && resp.data.memberships) || []; + const projectIds = memberships.map((m) => (m.project && m.project.gid) || null).filter(Boolean); + return projectIds.some((id) => noAutocloseList.includes(id)); + } catch (err) { + console.warn(`Unable to inspect memberships for task ${taskId}: ${err.message}`); + // Fail-open: if we can't determine membership, allow auto-close + return false; + } +} + function findAsanaTasks() { const triggerPhrase = core.getInput('trigger-phrase'); const pullRequest = github.context.payload.pull_request; @@ -96,6 +123,21 @@ async function createStory(client, taskId, text, isPinned) { } } +async function createSubtask(client, parentTaskId, subtaskId) { + try { + const body = { + data: { + parent: parentTaskId, + }, + }; + const opts = {}; + return await client.tasks.updateTask(body, subtaskId, opts); + } catch (error) { + console.error('Error creating subtask relationship:', error); + throw error; + } +} + async function createTaskWithComment(client, name, description, comment, projectId) { try { const body = { @@ -227,6 +269,695 @@ async function createPullRequestTask() { return createTaskWithComment(client, taskName, taskDescription, taskComment, asanaProjectId); } +async function createPRTask() { + const client = await buildAsanaClient(); + const pullRequest = github.context.payload.pull_request; + const asanaProjectId = core.getInput('asana-project', { required: true }); + const asanaSectionId = core.getInput('asana-section'); + + console.info('creating asana task for pull request', pullRequest.title); + + const taskName = pullRequest.title; + + // Create task description with prelude + const prelude = `**Note:** This description is automatically updated from Github. **Changes will be LOST**. + +PR: ${pullRequest.html_url} + +`; + const taskDescription = prelude + (pullRequest.body || 'No description provided'); + const tags = getArrayFromInput(core.getInput('asana-tags')); + const collaborators = getArrayFromInput(core.getInput('asana-collaborators')); + const assignee = core.getInput('asana-task-assignee'); + const customFields = core.getInput('asana-task-custom-fields'); + + // Check for referenced Asana tasks in PR description + const referencedTaskIds = findAsanaTasks(); + let parentTaskId = null; + + if (referencedTaskIds.length > 0) { + parentTaskId = referencedTaskIds[0]; + console.info(`Found referenced Asana task ${parentTaskId}, creating PR task as subtask`); + } else { + console.info('No referenced Asana tasks found, creating standalone PR task'); + } + + // Get Asana user ID for PR author + let prAuthorAsanaId = null; + if (core.getInput('github-pat')) { + prAuthorAsanaId = await getAsanaUserID(pullRequest.user.login); + } + + // Add PR link as a comment + const taskComment = `GitHub PR: ${pullRequest.html_url}`; + + const taskId = await createTask( + client, + taskName, + taskDescription, + asanaProjectId, + asanaSectionId, + tags, + collaborators, + prAuthorAsanaId || assignee, // Use PR author as assignee if found, otherwise use input assignee + customFields, + ); + + // If we found a referenced task, make this PR task a subtask + if (taskId && taskId !== '0' && parentTaskId) { + try { + await createSubtask(client, parentTaskId, taskId); + console.info(`Created PR task ${taskId} as subtask of ${parentTaskId}`); + } catch (error) { + console.error(`Failed to create subtask relationship: ${error.message}`); + // Continue execution - the task was still created successfully + } + } + + // Add PR link as a comment to the created task + if (taskId && taskId !== '0') { + await createStory(client, taskId, taskComment, true); + core.setOutput('asanaTaskId', taskId); + if (parentTaskId) { + core.setOutput('parentTaskId', parentTaskId); + } + console.info(`Created Asana task ${taskId} for PR ${pullRequest.number}`); + } + + return taskId; +} + +async function updatePRTask() { + const client = await buildAsanaClient(); + const pullRequest = github.context.payload.pull_request; + const asanaProjectId = core.getInput('asana-project', { required: true }); + + console.info('updating asana task for pull request', pullRequest.title); + + // Find the Asana task for this PR by looking for the PR URL in task comments + const taskId = await findPRTaskByURL(client, pullRequest.html_url, asanaProjectId); + + if (!taskId) { + console.warn(`No Asana task found for PR ${pullRequest.number}`); + core.setOutput('taskUpdated', false); + return; + } + + try { + // Update the task name and description to match the PR + const prelude = `**Note:** This description is automatically updated from Github. **Changes will be LOST**. + +PR: ${pullRequest.html_url} + +`; + const taskDescription = prelude + (pullRequest.body || 'No description provided'); + + const body = { + data: { + name: pullRequest.title, + notes: taskDescription, + }, + }; + const opts = {}; + + await client.tasks.updateTask(body, taskId, opts); + + // Add a comment about the update + const comment = `PR updated - Title: ${pullRequest.title}`; + await createStory(client, taskId, comment, false); + + core.setOutput('asanaTaskId', taskId); + core.setOutput('taskUpdated', true); + console.info(`Updated Asana task ${taskId} - Title: ${pullRequest.title}`); + } catch (error) { + console.error(`Failed to update task: ${error.message}`); + core.setFailed(`Failed to update Asana task: ${error.message}`); + } +} + +async function findPRTaskByURL(client, prURL, projectId) { + try { + // Get all tasks in the project + const response = await client.tasks.getTasksForProject(projectId, {}); + const tasks = response.data; + + // Look for a task that has a comment containing the PR URL + for (const task of tasks) { + try { + const storiesResponse = await client.stories.getStoriesForTask(task.gid, {}); + const stories = storiesResponse.data; + + // Check if any story contains the PR URL + const hasPRURL = stories.some((story) => story.text && story.text.includes(prURL)); + + if (hasPRURL) { + console.info(`Found Asana task ${task.gid} for PR ${prURL}`); + return task.gid; + } + } catch (storyError) { + // Continue searching if we can't get stories for this task + console.debug(`Could not get stories for task ${task.gid}: ${storyError.message}`); + } + } + + console.info(`No Asana task found with PR URL: ${prURL}`); + return null; + } catch (error) { + console.error(`Error searching for PR task: ${error.message}`); + return null; + } +} + +async function checkForApprovedReviews(pullRequest) { + const githubPAT = core.getInput('github-pat'); + if (!githubPAT) { + console.warn('GitHub PAT not provided, cannot check review status'); + return false; + } + + try { + const githubClient = buildGithubClient(githubPAT); + const owner = pullRequest.base.repo.owner.login; + const repo = pullRequest.base.repo.name; + const prNumber = pullRequest.number; + + // Get all reviews for the PR + const response = await githubClient.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { + owner, + repo, + pull_number: prNumber, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + const reviews = response.data; + + // Check if any review has state 'approved' + const hasApprovedReview = reviews.some((review) => review.state === 'approved'); + + console.info(`PR ${prNumber} has ${reviews.length} reviews, approved: ${hasApprovedReview}`); + return hasApprovedReview; + } catch (error) { + console.error(`Error checking reviews: ${error.message}`); + return false; + } +} + +async function updatePRState() { + const client = await buildAsanaClient(); + const pullRequest = github.context.payload.pull_request; + const asanaProjectId = core.getInput('asana-project', { required: true }); + const customFieldId = core.getInput('asana-review-custom-field', { required: true }); + + console.info('updating PR state for pull request', pullRequest.title); + + // Find the Asana task for this PR + const taskId = await findPRTaskByURL(client, pullRequest.html_url, asanaProjectId); + + if (!taskId) { + console.warn(`No Asana task found for PR ${pullRequest.number}`); + core.setOutput('stateUpdated', false); + return; + } + + try { + // Determine PR state + let prState = 'Open'; + if (pullRequest.state === 'closed') { + if (pullRequest.merged) { + prState = 'Merged'; + } else { + prState = 'Closed'; + } + } else if (pullRequest.draft) { + prState = 'Draft'; + } else { + // Check if PR has approved reviews + const hasApprovedReviews = await checkForApprovedReviews(pullRequest); + if (hasApprovedReviews) { + prState = 'Approved'; + } + } + + console.info(`PR state determined as: ${prState}`); + + // Update the custom field with PR state + const body = { + data: { + custom_fields: { + [customFieldId]: prState, + }, + }, + }; + const opts = {}; + + await client.tasks.updateTask(body, taskId, opts); + + // Add a comment about the state change + const comment = `PR state updated to: ${prState}`; + await createStory(client, taskId, comment, false); + + core.setOutput('asanaTaskId', taskId); + core.setOutput('prState', prState); + core.setOutput('stateUpdated', true); + console.info(`Updated Asana task ${taskId} PR state to: ${prState}`); + } catch (error) { + console.error(`Failed to update PR state: ${error.message}`); + core.setFailed(`Failed to update PR state: ${error.message}`); + } +} + +async function createReviewSubtasks() { + const client = await buildAsanaClient(); + const pullRequest = github.context.payload.pull_request; + const asanaProjectId = core.getInput('asana-project', { required: true }); + const asanaSectionId = core.getInput('asana-section'); + + console.info('creating review subtasks for pull request', pullRequest.title); + + // Find the main PR task + const prTaskId = await findPRTaskByURL(client, pullRequest.html_url, asanaProjectId); + + if (!prTaskId) { + console.warn(`No Asana task found for PR ${pullRequest.number}`); + core.setOutput('reviewSubtasksCreated', false); + return; + } + + try { + // Check if we should only assign to PR author + const assignPrAuthor = core.getInput('assign-pr-author') === 'true'; + + if (assignPrAuthor) { + console.info('ASSIGN_PR_AUTHOR is true - creating single subtask for PR author only'); + await createSingleAuthorSubtask(client, prTaskId, pullRequest, asanaProjectId, asanaSectionId); + return; + } + + // Get requested reviewers and assignees + const requestedReviewers = pullRequest.requested_reviewers || []; + const requestedTeams = pullRequest.requested_teams || []; + const assignees = pullRequest.assignees || []; + + console.info( + `Found ${requestedReviewers.length} requested reviewers, ${requestedTeams.length} requested teams, and ${assignees.length} assignees`, + ); + + const createdSubtasks = []; + const processedUsers = new Set(); // Track users to avoid duplicates + + // Create subtasks for individual reviewers + for (const reviewer of requestedReviewers) { + const subtaskName = `Code review for PR #${pullRequest.number}: ${pullRequest.title}`; + + // Create subtask description with prelude + const prelude = `${pullRequest.user.login} requested your code review of ${pullRequest.html_url}. + +NOTE: +* This task will be automatically closed when the review is completed in Github +* Do not add this task to another public projects +* Do not reassign to someone else +* Adjust due date as needed + +See parent task for more information + +`; + const subtaskDescription = prelude + (pullRequest.body || 'No description provided'); + + // Get Asana user ID for reviewer + let reviewerAsanaId = null; + if (core.getInput('github-pat')) { + reviewerAsanaId = await getAsanaUserID(reviewer.login); + } + + const subtaskId = await createTask( + client, + subtaskName, + subtaskDescription, + asanaProjectId, + asanaSectionId, + ['review', 'pending'], + [], + reviewerAsanaId || '', + '', + ); + + if (subtaskId && subtaskId !== '0') { + // Make it a subtask of the PR task + await createSubtask(client, prTaskId, subtaskId); + + // Add comment with reviewer info + const comment = `Review requested from @${reviewer.login}`; + await createStory(client, subtaskId, comment, true); + + createdSubtasks.push({ + id: subtaskId, + user: reviewer.login, + type: 'reviewer', + }); + + processedUsers.add(reviewer.login); + console.info(`Created review subtask ${subtaskId} for ${reviewer.login}`); + } + } + + // Create subtasks for assignees (if not already processed as reviewers) + for (const assignee of assignees) { + if (processedUsers.has(assignee.login)) { + console.info(`Skipping ${assignee.login} - already has review subtask`); + continue; + } + + const subtaskName = `Code review for PR #${pullRequest.number}: ${pullRequest.title}`; + + // Create subtask description with prelude + const prelude = `${pullRequest.user.login} requested your code review of ${pullRequest.html_url}. + +NOTE: +* This task will be automatically closed when the review is completed in Github +* Do not add this task to another public projects +* Do not reassign to someone else +* Adjust due date as needed + +See parent task for more information + +`; + const subtaskDescription = prelude + (pullRequest.body || 'No description provided'); + + // Get Asana user ID for assignee + let assigneeAsanaId = null; + if (core.getInput('github-pat')) { + assigneeAsanaId = await getAsanaUserID(assignee.login); + } + + const subtaskId = await createTask( + client, + subtaskName, + subtaskDescription, + asanaProjectId, + asanaSectionId, + ['assigned', 'pending'], + [], + assigneeAsanaId || '', + '', + ); + + if (subtaskId && subtaskId !== '0') { + // Make it a subtask of the PR task + await createSubtask(client, prTaskId, subtaskId); + + // Add comment with assignee info + const comment = `Assigned to @${assignee.login}`; + await createStory(client, subtaskId, comment, true); + + createdSubtasks.push({ + id: subtaskId, + user: assignee.login, + type: 'assignee', + }); + + processedUsers.add(assignee.login); + console.info(`Created assignment subtask ${subtaskId} for ${assignee.login}`); + } + } + + // Create subtasks for team reviewers + for (const team of requestedTeams) { + const subtaskName = `Code review for PR #${pullRequest.number}: ${pullRequest.title}`; + + // Create subtask description with prelude + const prelude = `${pullRequest.user.login} requested your code review of ${pullRequest.html_url}. + +NOTE: +* This task will be automatically closed when the review is completed in Github +* Do not add this task to another public projects +* Do not reassign to someone else +* Adjust due date as needed + +See parent task for more information + +`; + const subtaskDescription = prelude + (pullRequest.body || 'No description provided'); + + const subtaskId = await createTask( + client, + subtaskName, + subtaskDescription, + asanaProjectId, + asanaSectionId, + ['review', 'pending', 'team'], + [], + '', + '', + ); + + if (subtaskId && subtaskId !== '0') { + // Make it a subtask of the PR task + await createSubtask(client, prTaskId, subtaskId); + + // Add comment with team info + const comment = `Review requested from team @${team.name}`; + await createStory(client, subtaskId, comment, true); + + createdSubtasks.push({ + id: subtaskId, + reviewer: team.name, + type: 'team', + }); + + console.info(`Created review subtask ${subtaskId} for team ${team.name}`); + } + } + + core.setOutput('asanaTaskId', prTaskId); + core.setOutput('reviewSubtasksCreated', true); + core.setOutput('createdSubtasks', JSON.stringify(createdSubtasks)); + console.info(`Created ${createdSubtasks.length} review subtasks for PR ${pullRequest.number}`); + } catch (error) { + console.error(`Failed to create review subtasks: ${error.message}`); + core.setFailed(`Failed to create review subtasks: ${error.message}`); + } +} + +async function createSingleAuthorSubtask(client, prTaskId, pullRequest, asanaProjectId, asanaSectionId) { + const subtaskName = `Code review for PR #${pullRequest.number}: ${pullRequest.title}`; + + // Create subtask description with prelude + const prelude = `${pullRequest.user.login} requested your code review of ${pullRequest.html_url}. + +NOTE: +* This task will be automatically closed when the review is completed in Github +* Do not add this task to another public projects +* Do not reassign to someone else +* Adjust due date as needed + +See parent task for more information + +`; + const subtaskDescription = prelude + (pullRequest.body || 'No description provided'); + + // Get Asana user ID for PR author + let authorAsanaId = null; + if (core.getInput('github-pat')) { + authorAsanaId = await getAsanaUserID(pullRequest.user.login); + } + + const subtaskId = await createTask( + client, + subtaskName, + subtaskDescription, + asanaProjectId, + asanaSectionId, + ['review', 'author'], + [], + authorAsanaId || '', + '', + ); + + if (subtaskId && subtaskId !== '0') { + // Make it a subtask of the PR task + await createSubtask(client, prTaskId, subtaskId); + + // Add comment with author info + const comment = `Code review task assigned to PR author @${pullRequest.user.login}`; + await createStory(client, subtaskId, comment, true); + + core.setOutput('asanaTaskId', prTaskId); + core.setOutput('reviewSubtasksCreated', true); + core.setOutput( + 'createdSubtasks', + JSON.stringify([ + { + id: subtaskId, + user: pullRequest.user.login, + type: 'author', + }, + ]), + ); + console.info(`Created single author subtask ${subtaskId} for ${pullRequest.user.login}`); + } +} + +async function updateReviewSubtaskStatus() { + const client = await buildAsanaClient(); + const pullRequest = github.context.payload.pull_request; + const review = github.context.payload.review; + const asanaProjectId = core.getInput('asana-project', { required: true }); + + console.info('updating review subtask status for pull request', pullRequest.title); + + // Find the main PR task + const prTaskId = await findPRTaskByURL(client, pullRequest.html_url, asanaProjectId); + + if (!prTaskId) { + console.warn(`No Asana task found for PR ${pullRequest.number}`); + core.setOutput('reviewStatusUpdated', false); + return; + } + + try { + // Get all subtasks of the PR task + const subtasks = await getSubtasks(client, prTaskId); + + if (!review) { + console.info('No review data available, checking if PR was merged'); + // Check if PR was merged and resolve all pending subtasks + if (pullRequest.merged) { + await resolveAllPendingSubtasks(client, subtasks, 'PR was merged'); + core.setOutput('reviewStatusUpdated', true); + core.setOutput('action', 'merged'); + } + return; + } + + const reviewer = review.user.login; + const reviewState = review.state; + + console.info(`Review by ${reviewer}: ${reviewState}`); + + // Find the corresponding subtask for this reviewer + // Since all subtasks have the same name format, we need to find by description + const subtask = subtasks.find( + (task) => + task.name.includes(`Code review for PR #${pullRequest.number}:`) && + task.name.includes(pullRequest.title) && + task.notes && + task.notes.includes(`@${reviewer}`), + ); + + if (!subtask) { + console.warn(`No subtask found for reviewer ${reviewer}`); + core.setOutput('reviewStatusUpdated', false); + return; + } + + // Update subtask based on review state + if (reviewState === 'approved') { + await resolveReviewSubtask(client, subtask.gid, `Approved by @${reviewer}`); + console.info(`Resolved review subtask ${subtask.gid} - approved by ${reviewer}`); + } else if (reviewState === 'changes_requested') { + await updateReviewSubtask(client, subtask.gid, 'Changes requested', `Changes requested by @${reviewer}`); + console.info(`Updated review subtask ${subtask.gid} - changes requested by ${reviewer}`); + } else if (reviewState === 'commented') { + await updateReviewSubtask(client, subtask.gid, 'Commented', `Commented by @${reviewer}`); + console.info(`Updated review subtask ${subtask.gid} - commented by ${reviewer}`); + } + + core.setOutput('asanaTaskId', prTaskId); + core.setOutput('reviewStatusUpdated', true); + core.setOutput('reviewer', reviewer); + core.setOutput('reviewState', reviewState); + } catch (error) { + console.error(`Failed to update review subtask status: ${error.message}`); + core.setFailed(`Failed to update review subtask status: ${error.message}`); + } +} + +async function getSubtasks(client, parentTaskId) { + try { + const response = await client.tasks.getSubtasksForTask(parentTaskId, {}); + return response.data; + } catch (error) { + console.error(`Error getting subtasks: ${error.message}`); + return []; + } +} + +async function resolveReviewSubtask(client, subtaskId, comment) { + try { + // Skip auto-closing if subtask belongs to NO_AUTOCLOSE_PROJECTS + try { + if (await isTaskInNoAutocloseProjects(client, subtaskId)) { + console.info(`Skipping auto-complete for subtask ${subtaskId} because it belongs to NO_AUTOCLOSE_PROJECTS`); + return; + } + } catch (checkErr) { + console.warn(`Error checking NO_AUTOCLOSE_PROJECTS for ${subtaskId}: ${checkErr.message}`); + // continue to resolve if the check fails + } + + // Proceed to complete the subtask + const body = { + data: { + completed: true, + }, + }; + await client.tasks.updateTask(body, subtaskId, {}); + // Add resolution comment + await createStory(client, subtaskId, comment, true); + } catch (error) { + console.error(`Error resolving subtask ${subtaskId}: ${error.message}`); + throw error; + } +} + +async function updateReviewSubtask(client, subtaskId, newName, comment) { + try { + // Update task name and add comment + const body = { + data: { + name: newName, + }, + }; + await client.tasks.updateTask(body, subtaskId, {}); + + // Add comment + await createStory(client, subtaskId, comment, false); + } catch (error) { + console.error(`Error updating subtask ${subtaskId}: ${error.message}`); + throw error; + } +} + +async function resolveAllPendingSubtasks(client, subtasks, reason) { + if (!Array.isArray(subtasks)) subtasks = [subtasks]; + + const results = []; + for (const subtask of subtasks) { + const id = subtask && (subtask.gid || subtask.id) ? subtask.gid || subtask.id : subtask; + try { + if (!id) continue; + + // Skip if subtask is in NO_AUTOCLOSE_PROJECTS + if (await isTaskInNoAutocloseProjects(client, id)) { + console.info(`Skipping auto-complete for subtask ${id} (NO_AUTOCLOSE_PROJECTS)`); + results.push({ id, skipped: true }); + continue; + } + + // Attempt to resolve subtask + await resolveReviewSubtask(client, id, reason); + results.push({ id, skipped: false, success: true }); + } catch (err) { + console.error(`Error resolving subtask ${id}: ${err.message}`); + results.push({ id, skipped: false, success: false, error: err.message }); + } + } + + return results; +} + async function completePRTask() { const isComplete = core.getInput('is-complete') === 'true'; @@ -418,34 +1149,44 @@ async function addTaskPRDescription() { }); } -async function getAsanaUserID() { - const ghUsername = core.getInput('github-username') || github.context.payload.pull_request.user.login; - const githubPAT = core.getInput('github-pat', { required: true }); - const githubClient = buildGithubClient(githubPAT); - const org = 'duckduckgo'; - const repo = 'internal-github-asana-utils'; - - console.log(`Looking up Asana user ID for ${ghUsername}`); +async function getAsanaUserID(ghUsername = null) { try { - await githubClient - .request('GET /repos/{owner}/{repo}/contents/user_map.yml', { - owner: org, - repo, - headers: { - 'X-GitHub-Api-Version': '2022-11-28', - Accept: 'application/vnd.github.raw+json', - }, - }) - .then((response) => { - const userMap = yaml.load(response.data); - if (ghUsername in userMap) { - core.setOutput('asanaUserId', userMap[ghUsername]); - } else { - core.setFailed(`User ${ghUsername} not found in user map`); - } - }); + // Get username from input or context, with proper fallback + const username = ghUsername || core.getInput('github-username') || + (github.context.payload.pull_request && github.context.payload.pull_request.user.login); + + if (!username) { + throw new Error('No GitHub username provided'); + } + + const githubPAT = core.getInput('github-pat', { required: true }); + const githubClient = buildGithubClient(githubPAT); + const org = 'duckduckgo'; + const repo = 'internal-github-asana-utils'; + + console.log(`Looking up Asana user ID for ${username}`); + + // Fetch user map + const response = await githubClient.request('GET /repos/{owner}/{repo}/contents/user_map.yml', { + owner: org, + repo: repo, + }); + + const content = Buffer.from(response.data.content, 'base64').toString(); + const userMap = yaml.load(content); + + if (!userMap || !userMap[username]) { + throw new Error(`User ${username} not found in user map`); + } + + const asanaUserId = userMap[username]; + core.setOutput('asanaUserId', asanaUserId); + return asanaUserId; + } catch (error) { - core.setFailed(error); + console.error(`Error in getAsanaUserID: ${error.message}`); + core.setFailed(error.message || 'Failed to get Asana user ID'); + throw error; // Re-throw to ensure promise rejection } } @@ -565,11 +1306,84 @@ async function sendMattermostMessage() { } } +async function asanaPRSync() { + const eventName = github.context.eventName; + const pullRequest = github.context.payload.pull_request; + const review = github.context.payload.review; + + console.info(`GitHub event: ${eventName}`); + + if (!pullRequest) { + core.setFailed('This action only works with pull request events'); + return; + } + + try { + if (eventName === 'pull_request') { + await handlePullRequestEvent(pullRequest); + } else if (eventName === 'pull_request_review') { + await handlePullRequestReviewEvent(pullRequest, review); + } else { + core.setFailed(`Unsupported event type: ${eventName}`); + } + } catch (error) { + console.error(`Error in asana-pr-sync: ${error.message}`); + core.setFailed(`Asana PR sync failed: ${error.message}`); + } +} + +async function handlePullRequestEvent(pullRequest) { + const action = github.context.payload.action; + console.info(`PR action: ${action}`); + + switch (action) { + case 'opened': + console.info('PR opened - creating task and subtasks'); + await createPRTask(); + await createReviewSubtasks(); + break; + + case 'edited': + console.info('PR edited - updating task'); + await updatePRTask(); + break; + + case 'closed': + console.info('PR closed - updating state and resolving subtasks'); + await updatePRState(); + await updateReviewSubtaskStatus(); + break; + + case 'review_requested': + console.info('Review requested - creating review subtasks'); + await createReviewSubtasks(); + break; + + case 'assigned': + console.info('PR assigned - creating assignment subtasks'); + await createReviewSubtasks(); + break; + + default: + console.info(`PR action ${action} - updating state only`); + await updatePRState(); + } +} + +async function handlePullRequestReviewEvent(pullRequest, review) { + console.info(`PR review event - updating subtask status`); + await updateReviewSubtaskStatus(); +} + async function action() { const action = core.getInput('action', { required: true }); console.info('calling', action); switch (action) { + case 'asana-pr-sync': { + await asanaPRSync(); + break; + } case 'create-asana-issue-task': { await createIssueTask(); break; @@ -598,6 +1412,10 @@ async function action() { await createPullRequestTask(); break; } + case 'create-pr-task': { + await createPRTask(); + break; + } case 'get-latest-repo-release': { await getLatestRepositoryRelease(); break; diff --git a/action.yml b/action.yml index d62300a..4eaa61c 100644 --- a/action.yml +++ b/action.yml @@ -78,7 +78,16 @@ inputs: asana-task-custom-fields: description: 'Asana task custom fields hash, encoded as a JSON string' required: false - + asana-review-custom-field: + description: 'Asana custom field ID for storing PR state (Open, Closed, Draft, Merged, Approved)' + required: false + assign-pr-author: + description: 'If true, creates only one subtask assigned to PR author instead of separate tasks for reviewers/assignees' + required: false + default: 'false' + NO_AUTOCLOSE_PROJECTS: + description: 'Comma-separated list of Asana project GIDs for which review subtasks should NOT be auto-closed when a PR review is approved.' + required: false runs: using: 'node20' main: 'dist/index.js' diff --git a/package-lock.json b/package-lock.json index 42a6e9b..084d528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -637,9 +637,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", - "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -679,13 +679,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -694,19 +694,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -754,19 +757,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -774,32 +780,19 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@fastify/busboy": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", @@ -1642,9 +1635,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -2843,33 +2836,32 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3159,9 +3151,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3176,9 +3168,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3251,15 +3243,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3491,14 +3483,15 @@ } }, "node_modules/form-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", - "integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.35" }, "engines": { diff --git a/tests/action.test.js b/tests/action.test.js index d0d067d..a17f815 100644 --- a/tests/action.test.js +++ b/tests/action.test.js @@ -783,6 +783,18 @@ describe('GitHub Asana Sync Action', () => { describe('action: get-asana-user-id', () => { const ghUser = 'test-user'; + const mockBase64Content = Buffer.from(JSON.stringify(mockUserMap)).toString('base64'); + + beforeEach(() => { + jest.clearAllMocks(); + mockOctokitRequest.mockReset(); + mockOctokitRequest.mockResolvedValue({ + data: { + content: mockBase64Content, + encoding: 'base64' + } + }); + }); it('should get Asana user ID from map using input username', async () => { mockGetInput({ @@ -790,7 +802,6 @@ describe('GitHub Asana Sync Action', () => { 'github-pat': 'mock-github-pat', 'github-username': ghUser, }); - // yaml.load already mocked to return mockUserMap await action(); @@ -810,9 +821,7 @@ describe('GitHub Asana Sync Action', () => { mockGetInput({ action: 'get-asana-user-id', 'github-pat': 'mock-github-pat', - // No github-username input }); - // Context payload already has pull_request.user.login = 'test-user' await action(); @@ -830,12 +839,24 @@ describe('GitHub Asana Sync Action', () => { 'github-username': unknownUser, }); - await action(); + // Mock empty user map + mockOctokitRequest.mockResolvedValue({ + data: { + content: Buffer.from(JSON.stringify({})).toString('base64'), + encoding: 'base64' + } + }); - expect(mockOctokitRequest).toHaveBeenCalled(); - expect(yaml.load).toHaveBeenCalled(); - expect(core.setOutput).not.toHaveBeenCalled(); - expect(core.setFailed).toHaveBeenCalledWith(`User ${unknownUser} not found in user map`); + try { + await action(); + fail('Expected action to throw'); + } catch (error) { + expect(error.message).toBe(`User ${unknownUser} not found in user map`); + expect(mockOctokitRequest).toHaveBeenCalled(); + expect(yaml.load).toHaveBeenCalled(); + expect(core.setOutput).not.toHaveBeenCalled(); + expect(core.setFailed).toHaveBeenCalledWith(`User ${unknownUser} not found in user map`); + } }); it('should fail if GitHub request fails', async () => { @@ -844,15 +865,20 @@ describe('GitHub Asana Sync Action', () => { 'github-pat': 'mock-github-pat', 'github-username': 'test-user', }); + const error = new Error('API Error'); mockOctokitRequest.mockRejectedValue(error); - await action(); - - expect(mockOctokitRequest).toHaveBeenCalled(); - expect(yaml.load).not.toHaveBeenCalled(); - expect(core.setOutput).not.toHaveBeenCalled(); - expect(core.setFailed).toHaveBeenCalledWith(error); + try { + await action(); + fail('Expected action to throw'); + } catch (error) { + expect(error.message).toBe('API Error'); + expect(mockOctokitRequest).toHaveBeenCalled(); + expect(yaml.load).not.toHaveBeenCalled(); + expect(core.setOutput).not.toHaveBeenCalled(); + expect(core.setFailed).toHaveBeenCalledWith('API Error'); + } }); });