From cf3f83af2501ab3b8751db7814116239f6c43d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 12:23:23 +0200 Subject: [PATCH 1/4] add initial version for the new pr sync --- action.js | 785 ++++++++++++++++++++++++++++++++++++++++++++++++++++- action.yml | 7 + 2 files changed, 779 insertions(+), 13 deletions(-) diff --git a/action.js b/action.js index 7478ad6..2a8ece7 100644 --- a/action.js +++ b/action.js @@ -96,6 +96,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 +242,671 @@ 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 { + // Mark as complete + 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) { + for (const subtask of subtasks) { + if (subtask.name.includes('Code review for PR #') && !subtask.completed) { + try { + await resolveReviewSubtask(client, subtask.gid, reason); + console.info(`Resolved pending subtask ${subtask.gid} - ${reason}`); + } catch (error) { + console.error(`Failed to resolve subtask ${subtask.gid}: ${error.message}`); + } + } + } +} + async function completePRTask() { const isComplete = core.getInput('is-complete') === 'true'; @@ -418,16 +1098,16 @@ async function addTaskPRDescription() { }); } -async function getAsanaUserID() { - const ghUsername = core.getInput('github-username') || github.context.payload.pull_request.user.login; +async function getAsanaUserID(ghUsername = null) { + const username = 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}`); + console.log(`Looking up Asana user ID for ${username}`); try { - await githubClient + const response = await githubClient .request('GET /repos/{owner}/{repo}/contents/user_map.yml', { owner: org, repo, @@ -435,17 +1115,19 @@ async function getAsanaUserID() { '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`); - } }); + + const userMap = yaml.load(response.data); + if (username in userMap) { + console.log(`Found Asana user ID for ${username}: ${userMap[username]}`); + return userMap[username]; + } else { + console.warn(`User ${username} not found in user map`); + return null; + } } catch (error) { - core.setFailed(error); + console.error(`Error looking up Asana user ID for ${username}: ${error.message}`); + return null; } } @@ -565,11 +1247,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 +1353,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..188f88b 100644 --- a/action.yml +++ b/action.yml @@ -78,6 +78,13 @@ 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' runs: using: 'node20' From 8619dd075188f0a20f8e74727cbe80e5eb7d0de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 12:29:11 +0200 Subject: [PATCH 2/4] Add support for NO_AUTOCLOSE_PROJECTS to prevent subtask auto-closing --- action.js | 293 +++++++++++------------------------------------------ action.yml | 4 +- 2 files changed, 64 insertions(+), 233 deletions(-) diff --git a/action.js b/action.js index 2a8ece7..7a45f5c 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; @@ -858,17 +885,26 @@ async function getSubtasks(client, parentTaskId) { async function resolveReviewSubtask(client, subtaskId, comment) { try { - // Mark as complete + // 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; @@ -895,16 +931,31 @@ async function updateReviewSubtask(client, subtaskId, newName, comment) { } async function resolveAllPendingSubtasks(client, subtasks, reason) { + if (!Array.isArray(subtasks)) subtasks = [subtasks]; + + const results = []; for (const subtask of subtasks) { - if (subtask.name.includes('Code review for PR #') && !subtask.completed) { - try { - await resolveReviewSubtask(client, subtask.gid, reason); - console.info(`Resolved pending subtask ${subtask.gid} - ${reason}`); - } catch (error) { - console.error(`Failed to resolve subtask ${subtask.gid}: ${error.message}`); + 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() { @@ -1183,226 +1234,4 @@ async function postCommentAsanaTask() { for (const taskId of taskIds) { console.info(`Adding comment to Asana task ${taskId}`); const comment = await createStory(client, taskId, taskComment, isPinned); - if (comment == null) { - console.error(`Failed to add comment to task ${taskId}`); - success = false; - } - } - - if (success) { - console.info(`Comments added to ${taskIds.length} Asana task(s)`); - } else { - core.setFailed(`Failed to post comments to one or more Asana tasks`); - } -} - -async function markAsanaTaskComplete() { - const taskId = core.getInput('asana-task-id', { required: true }); - const isComplete = core.getInput('is-complete') === 'true'; - - return completeAsanaTask(taskId, isComplete); -} - -async function completeAsanaTask(taskId, completed) { - const client = await buildAsanaClient(); - const body = { - data: { - completed, - }, - }; - const opts = {}; - try { - await client.tasks.updateTask(body, taskId, opts); - } catch (error) { - console.error('Error completing task:', JSON.stringify(error)); - core.setFailed(`Error completing task ${taskId}: ${error.message}`); - } -} - -async function sendMessage(client, channelId, message) { - try { - const response = await client.createPost({ - channel_id: channelId, - message, - }); - console.log('Message sent:', response); - } catch (error) { - core.setFailed(`Error sending message`); - } -} - -async function sendMattermostMessage() { - const channelName = core.getInput('mattermost-channel-name'); - const message = core.getInput('mattermost-message'); - const teamId = core.getInput('mattermost-team-id'); - - const client = buildMattermostClient(); - - const channel = await client.getChannelByName(teamId, channelName); - if (channel) { - console.log(`Channel "${channel.id}" found.`); - await sendMessage(client, channel.id, message); - } else { - core.setFailed(`Channel "${channelName}" not found.`); - } -} - -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; - } - case 'notify-pr-approved': { - await notifyPRApproved(); - break; - } - case 'notify-pr-merged': { - await completePRTask(); - break; - } - case 'check-pr-membership': { - await checkPRMembership(); - break; - } - case 'add-asana-comment': { - await addCommentToPRTask(); - break; - } - case 'add-task-asana-project': { - await addTaskToAsanaProject(); - break; - } - case 'create-asana-pr-task': { - await createPullRequestTask(); - break; - } - case 'create-pr-task': { - await createPRTask(); - break; - } - case 'get-latest-repo-release': { - await getLatestRepositoryRelease(); - break; - } - case 'create-asana-task': { - await createAsanaTask(); - break; - } - case 'add-task-pr-description': { - await addTaskPRDescription(); - break; - } - case 'get-asana-user-id': { - await getAsanaUserID(); - break; - } - case 'find-asana-task-id': { - await findAsanaTaskId(); - break; - } - case 'find-asana-task-ids': { - await findAsanaTaskIds(); - break; - } - case 'post-comment-asana-task': { - await postCommentAsanaTask(); - break; - } - case 'send-mattermost-message': { - await sendMattermostMessage(); - break; - } - case 'get-asana-task-permalink': { - await getTaskPermalink(); - break; - } - case 'mark-asana-task-complete': { - await markAsanaTaskComplete(); - break; - } - default: - core.setFailed(`unexpected action ${action}`); - } -} - -module.exports = { - action, - default: action, -}; + if diff --git a/action.yml b/action.yml index 188f88b..4eaa61c 100644 --- a/action.yml +++ b/action.yml @@ -85,7 +85,9 @@ inputs: 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' From 0955f234e85846e9d48a32bc3ea78b7e9ee12bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 12:33:36 +0200 Subject: [PATCH 3/4] fix merge --- action.js | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/action.js b/action.js index 7a45f5c..50a3e4b 100644 --- a/action.js +++ b/action.js @@ -1234,4 +1234,226 @@ async function postCommentAsanaTask() { for (const taskId of taskIds) { console.info(`Adding comment to Asana task ${taskId}`); const comment = await createStory(client, taskId, taskComment, isPinned); - if + if (comment == null) { + console.error(`Failed to add comment to task ${taskId}`); + success = false; + } + } + + if (success) { + console.info(`Comments added to ${taskIds.length} Asana task(s)`); + } else { + core.setFailed(`Failed to post comments to one or more Asana tasks`); + } +} + +async function markAsanaTaskComplete() { + const taskId = core.getInput('asana-task-id', { required: true }); + const isComplete = core.getInput('is-complete') === 'true'; + + return completeAsanaTask(taskId, isComplete); +} + +async function completeAsanaTask(taskId, completed) { + const client = await buildAsanaClient(); + const body = { + data: { + completed, + }, + }; + const opts = {}; + try { + await client.tasks.updateTask(body, taskId, opts); + } catch (error) { + console.error('Error completing task:', JSON.stringify(error)); + core.setFailed(`Error completing task ${taskId}: ${error.message}`); + } +} + +async function sendMessage(client, channelId, message) { + try { + const response = await client.createPost({ + channel_id: channelId, + message, + }); + console.log('Message sent:', response); + } catch (error) { + core.setFailed(`Error sending message`); + } +} + +async function sendMattermostMessage() { + const channelName = core.getInput('mattermost-channel-name'); + const message = core.getInput('mattermost-message'); + const teamId = core.getInput('mattermost-team-id'); + + const client = buildMattermostClient(); + + const channel = await client.getChannelByName(teamId, channelName); + if (channel) { + console.log(`Channel "${channel.id}" found.`); + await sendMessage(client, channel.id, message); + } else { + core.setFailed(`Channel "${channelName}" not found.`); + } +} + +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; + } + case 'notify-pr-approved': { + await notifyPRApproved(); + break; + } + case 'notify-pr-merged': { + await completePRTask(); + break; + } + case 'check-pr-membership': { + await checkPRMembership(); + break; + } + case 'add-asana-comment': { + await addCommentToPRTask(); + break; + } + case 'add-task-asana-project': { + await addTaskToAsanaProject(); + break; + } + case 'create-asana-pr-task': { + await createPullRequestTask(); + break; + } + case 'create-pr-task': { + await createPRTask(); + break; + } + case 'get-latest-repo-release': { + await getLatestRepositoryRelease(); + break; + } + case 'create-asana-task': { + await createAsanaTask(); + break; + } + case 'add-task-pr-description': { + await addTaskPRDescription(); + break; + } + case 'get-asana-user-id': { + await getAsanaUserID(); + break; + } + case 'find-asana-task-id': { + await findAsanaTaskId(); + break; + } + case 'find-asana-task-ids': { + await findAsanaTaskIds(); + break; + } + case 'post-comment-asana-task': { + await postCommentAsanaTask(); + break; + } + case 'send-mattermost-message': { + await sendMattermostMessage(); + break; + } + case 'get-asana-task-permalink': { + await getTaskPermalink(); + break; + } + case 'mark-asana-task-complete': { + await markAsanaTaskComplete(); + break; + } + default: + core.setFailed(`unexpected action ${action}`); + } +} + +module.exports = { + action, + default: action, +}; From 07a21541793c5ae35c5e114c4caa9b75f73e5bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 16:11:45 +0200 Subject: [PATCH 4/4] fixed tests --- action.js | 236 ++++++++++++++++++++++--------------------- package-lock.json | 125 +++++++++++------------ tests/action.test.js | 54 +++++++--- 3 files changed, 221 insertions(+), 194 deletions(-) diff --git a/action.js b/action.js index 50a3e4b..1a47111 100644 --- a/action.js +++ b/action.js @@ -69,8 +69,8 @@ async function isTaskInNoAutocloseProjects(client, taskId) { 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)); + 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 @@ -278,7 +278,7 @@ async function createPRTask() { 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**. @@ -294,7 +294,7 @@ PR: ${pullRequest.html_url} // 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`); @@ -320,7 +320,7 @@ PR: ${pullRequest.html_url} tags, collaborators, prAuthorAsanaId || assignee, // Use PR author as assignee if found, otherwise use input assignee - customFields + customFields, ); // If we found a referenced task, make this PR task a subtask @@ -356,7 +356,7 @@ async function updatePRTask() { // 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); @@ -371,7 +371,7 @@ PR: ${pullRequest.html_url} `; const taskDescription = prelude + (pullRequest.body || 'No description provided'); - + const body = { data: { name: pullRequest.title, @@ -379,17 +379,16 @@ PR: ${pullRequest.html_url} }, }; 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}`); @@ -401,18 +400,16 @@ async function findPRTaskByURL(client, prURL, projectId) { // 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) - ); - + 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; @@ -422,10 +419,9 @@ async function findPRTaskByURL(client, prURL, projectId) { 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; @@ -456,13 +452,12 @@ async function checkForApprovedReviews(pullRequest) { }); const reviews = response.data; - + // Check if any review has state 'approved' - const hasApprovedReview = reviews.some(review => review.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; @@ -479,7 +474,7 @@ async function updatePRState() { // 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); @@ -511,23 +506,22 @@ async function updatePRState() { const body = { data: { custom_fields: { - [customFieldId]: prState - } + [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}`); @@ -544,7 +538,7 @@ async function createReviewSubtasks() { // 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); @@ -554,7 +548,7 @@ async function createReviewSubtasks() { 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); @@ -565,8 +559,10 @@ async function createReviewSubtasks() { 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`); + + 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 @@ -574,7 +570,7 @@ async function createReviewSubtasks() { // 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}. @@ -588,13 +584,13 @@ 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, @@ -604,23 +600,23 @@ See parent task for more information ['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' + type: 'reviewer', }); - + processedUsers.add(reviewer.login); console.info(`Created review subtask ${subtaskId} for ${reviewer.login}`); } @@ -634,7 +630,7 @@ See parent task for more information } 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}. @@ -648,13 +644,13 @@ 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, @@ -664,23 +660,23 @@ See parent task for more information ['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' + type: 'assignee', }); - + processedUsers.add(assignee.login); console.info(`Created assignment subtask ${subtaskId} for ${assignee.login}`); } @@ -689,7 +685,7 @@ See parent task for more information // 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}. @@ -703,7 +699,7 @@ See parent task for more information `; const subtaskDescription = prelude + (pullRequest.body || 'No description provided'); - + const subtaskId = await createTask( client, subtaskName, @@ -713,23 +709,23 @@ See parent task for more information ['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' + type: 'team', }); - + console.info(`Created review subtask ${subtaskId} for team ${team.name}`); } } @@ -738,7 +734,6 @@ See parent task for more information 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}`); @@ -747,7 +742,7 @@ See parent task for more information 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}. @@ -761,13 +756,13 @@ 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, @@ -777,24 +772,29 @@ See parent task for more information ['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' - }])); + core.setOutput( + 'createdSubtasks', + JSON.stringify([ + { + id: subtaskId, + user: pullRequest.user.login, + type: 'author', + }, + ]), + ); console.info(`Created single author subtask ${subtaskId} for ${pullRequest.user.login}`); } } @@ -809,7 +809,7 @@ async function updateReviewSubtaskStatus() { // 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); @@ -819,7 +819,7 @@ async function updateReviewSubtaskStatus() { 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 @@ -833,15 +833,17 @@ async function updateReviewSubtaskStatus() { 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}`)) + 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) { @@ -866,7 +868,6 @@ async function updateReviewSubtaskStatus() { 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}`); @@ -920,10 +921,9 @@ async function updateReviewSubtask(client, subtaskId, newName, comment) { }, }; 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; @@ -935,7 +935,7 @@ async function resolveAllPendingSubtasks(client, subtasks, reason) { const results = []; for (const subtask of subtasks) { - const id = subtask && (subtask.gid || subtask.id) ? (subtask.gid || subtask.id) : subtask; + const id = subtask && (subtask.gid || subtask.id) ? subtask.gid || subtask.id : subtask; try { if (!id) continue; @@ -1150,35 +1150,43 @@ async function addTaskPRDescription() { } async function getAsanaUserID(ghUsername = null) { - const username = 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 ${username}`); try { - const response = 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', - }, - }); - - const userMap = yaml.load(response.data); - if (username in userMap) { - console.log(`Found Asana user ID for ${username}: ${userMap[username]}`); - return userMap[username]; - } else { - console.warn(`User ${username} not found in user map`); - return null; + // 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) { - console.error(`Error looking up Asana user ID for ${username}: ${error.message}`); - return null; + 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 } } @@ -1302,9 +1310,9 @@ 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; @@ -1327,35 +1335,35 @@ async function asanaPRSync() { 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(); 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'); + } }); });