Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 85 additions & 69 deletions actions/setup/js/add_reaction.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,98 +36,114 @@ async function main() {
}

// Determine the API endpoint based on the event type
let reactionEndpoint;
const eventName = context.eventName;
const owner = context.repo.owner;
const repo = context.repo.repo;

try {
switch (eventName) {
case "issues": {
const issueNumber = context.payload?.issue?.number;
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`;
break;
/** @type {string | undefined} */
let reactionEndpoint;

switch (eventName) {
case "issues": {
const issueNumber = context.payload?.issue?.number;
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`;
break;
}

case "issue_comment": {
const commentId = context.payload?.comment?.id;
if (!commentId) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`;
break;
case "issue_comment": {
const commentId = context.payload?.comment?.id;
if (!commentId) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`;
break;
}

case "pull_request": {
const prNumber = context.payload?.pull_request?.number;
if (!prNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return;
}
// PRs are "issues" for the reactions endpoint
reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`;
break;
case "pull_request": {
const prNumber = context.payload?.pull_request?.number;
if (!prNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return;
}
// PRs are "issues" for the reactions endpoint
reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`;
break;
}

case "pull_request_review_comment": {
const reviewCommentId = context.payload?.comment?.id;
if (!reviewCommentId) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`;
break;
case "pull_request_review_comment": {
const reviewCommentId = context.payload?.comment?.id;
if (!reviewCommentId) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`;
break;
}

case "discussion": {
const discussionNumber = context.payload?.discussion?.number;
if (!discussionNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`);
return;
}
// Discussions use GraphQL API - get the node ID
case "discussion": {
const discussionNumber = context.payload?.discussion?.number;
if (!discussionNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`);
return;
}
// Discussions use GraphQL API - get the node ID
try {
const discussionNodeId = await getDiscussionNodeId(owner, repo, discussionNumber);
await addDiscussionReaction(discussionNodeId, reaction);
return; // Early return for discussion events
} catch (error) {
handleReactionError(error);
}
return;
}

case "discussion_comment": {
const commentNodeId = context.payload?.comment?.node_id;
if (!commentNodeId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
return;
}
case "discussion_comment": {
const commentNodeId = context.payload?.comment?.node_id;
if (!commentNodeId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
return;
}
try {
await addDiscussionReaction(commentNodeId, reaction);
return; // Early return for discussion comment events
} catch (error) {
handleReactionError(error);
}

default:
core.setFailed(`${ERR_VALIDATION}: Unsupported event type: ${eventName}`);
return;
return;
}

// Add reaction using REST API (for non-discussion events)
core.info(`Adding reaction to: ${reactionEndpoint}`);
await addReaction(/** @type {string} */ reactionEndpoint, reaction);
} catch (error) {
const errorMessage = getErrorMessage(error);

// Check if the error is due to a locked issue/PR/discussion
if (isLockedError(error)) {
// Silently ignore locked resource errors - just log for debugging
core.info(`Cannot add reaction: resource is locked (this is expected and not an error)`);
default:
core.setFailed(`${ERR_VALIDATION}: Unsupported event type: ${eventName}`);
return;
}
}

// For other errors, fail as before
core.error(`Failed to add reaction: ${errorMessage}`);
core.setFailed(`${ERR_API}: Failed to add reaction: ${errorMessage}`);
// Add reaction using REST API (for non-discussion events)
// reactionEndpoint is always defined here - all other cases return early
if (!reactionEndpoint) return;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (!reactionEndpoint) return; guard can cause a silent no-op if a new switch branch is added later that forgets to set reactionEndpoint (or changes control flow), making failures harder to detect. Consider failing explicitly here (e.g., core.setFailed with an internal error) so unexpected states don’t pass quietly.

Suggested change
if (!reactionEndpoint) return;
if (!reactionEndpoint) {
core.setFailed(`${ERR_API}: Internal error: reaction endpoint not set for event: ${eventName}`);
return;
}

Copilot uses AI. Check for mistakes.
core.info(`Adding reaction to: ${reactionEndpoint}`);
try {
await addReaction(reactionEndpoint, reaction);
} catch (error) {
handleReactionError(error);
}
}

/**
* Handle errors from reaction API calls consistently
* @param {unknown} error - The error to handle
*/
function handleReactionError(error) {
if (isLockedError(error)) {
// Silently ignore locked resource errors - just log for debugging
core.info(`Cannot add reaction: resource is locked (this is expected and not an error)`);
return;
}
const errorMessage = getErrorMessage(error);
core.error(`Failed to add reaction: ${errorMessage}`);
core.setFailed(`${ERR_API}: Failed to add reaction: ${errorMessage}`);
}

/**
Expand Down
54 changes: 53 additions & 1 deletion actions/setup/js/add_reaction.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ describe("add_reaction", () => {
expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion number not found in event payload`);
});

it("should handle discussion not found error", async () => {
it("should handle discussion not found error when repository is null", async () => {
global.context = {
eventName: "discussion",
repo: { owner: "testowner", repo: "testrepo" },
Expand All @@ -286,6 +286,40 @@ describe("add_reaction", () => {
expect(mockCore.error).toHaveBeenCalled();
expect(mockCore.setFailed).toHaveBeenCalled();
});

it("should handle discussion not found error when discussion is null", async () => {
global.context = {
eventName: "discussion",
repo: { owner: "testowner", repo: "testrepo" },
payload: { discussion: { number: 999 } },
};

mockGithub.graphql.mockResolvedValueOnce({ repository: { discussion: null } });

await runScript();

expect(mockCore.error).toHaveBeenCalled();
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("not found"));
});

it("should silently ignore locked discussion errors", async () => {
global.context = {
eventName: "discussion",
repo: { owner: "testowner", repo: "testrepo" },
payload: { discussion: { number: 100 } },
};

const lockedError = new Error("Issue is locked");
lockedError.status = 403;
// First call succeeds (getDiscussionNodeId query), second throws (addDiscussionReaction mutation)
mockGithub.graphql.mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABCD1234" } } }).mockRejectedValueOnce(lockedError);

await runScript();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("resource is locked"));
expect(mockCore.error).not.toHaveBeenCalled();
expect(mockCore.setFailed).not.toHaveBeenCalled();
});
});

describe("discussion_comment events", () => {
Expand Down Expand Up @@ -320,6 +354,24 @@ describe("add_reaction", () => {

expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
});

it("should silently ignore locked discussion comment errors", async () => {
global.context = {
eventName: "discussion_comment",
repo: { owner: "testowner", repo: "testrepo" },
payload: { comment: { node_id: "DC_kwDOABCD5678" } },
};

const lockedError = new Error("Issue is locked");
lockedError.status = 403;
mockGithub.graphql.mockRejectedValueOnce(lockedError);

await runScript();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("resource is locked"));
expect(mockCore.error).not.toHaveBeenCalled();
expect(mockCore.setFailed).not.toHaveBeenCalled();
});
});

describe("reaction mapping for GraphQL", () => {
Expand Down
Loading