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
227 changes: 123 additions & 104 deletions actions/setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { addReaction, addDiscussionReaction } = require("./add_reaction.cjs");

/**
* Event type descriptions for comment messages
* @type {Record<string, string>}
*/
const EVENT_TYPE_DESCRIPTIONS = {
issues: "issue",
Expand All @@ -23,6 +24,119 @@ const EVENT_TYPE_DESCRIPTIONS = {
discussion_comment: "discussion comment",
};

/** Valid GitHub reaction types */
const VALID_REACTIONS = Object.freeze(["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]);

/**
* Resolve the reaction and comment API endpoints for a given event.
* Returns null (after calling core.setFailed) when the event or payload is invalid.
* @param {string} eventName - The GitHub event name
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {Record<string, any>} payload - The event payload
* @returns {Promise<{reactionEndpoint: string, commentUpdateEndpoint: string} | null>}
*/
async function resolveEventEndpoints(eventName, owner, repo, payload) {
switch (eventName) {
case "issues": {
const issueNumber = payload?.issue?.number;
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return null;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/zoom-out] resolveEventEndpoints calls core.setFailed directly, coupling a pure endpoint-resolver to the GitHub Actions runtime. This makes the function harder to reuse or test without mocking core. Consider letting it throw a typed error and keeping the core.setFailed call in main():

// resolveEventEndpoints throws on invalid input
// main() catches and calls core.setFailed
try {
  const endpoints = await resolveEventEndpoints(eventName, owner, repo, payload);
  ...
} catch (err) {
  core.setFailed(err.message);
  return;
}

Minor point given the existing pattern in the file — feel free to defer.

return {
reactionEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`,
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
};
}

case "issue_comment": {
const commentId = payload?.comment?.id;
const issueNumber = payload?.issue?.number;
if (!commentId) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return null;
}
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return null;
}
return {
reactionEndpoint: `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
// Create new comment on the issue itself, not on the comment
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
};
}

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

case "pull_request_review_comment": {
const reviewCommentId = payload?.comment?.id;
const prNumber = payload?.pull_request?.number;
if (!reviewCommentId) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return null;
}
if (!prNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return null;
}
return {
reactionEndpoint: `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`,
// Create new comment on the PR itself (using issues endpoint since PRs are issues)
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${prNumber}/comments`,
};
}

case "discussion": {
const discussionNumber = payload?.discussion?.number;
if (!discussionNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`);
return null;
}
// Discussions use GraphQL API - get the node ID
const discussion = await getDiscussionId(owner, repo, discussionNumber);
return {
reactionEndpoint: discussion.id, // Store node ID for GraphQL
commentUpdateEndpoint: `discussion:${discussionNumber}`, // Special format to indicate discussion
};
}

case "discussion_comment": {
const discussionNumber = payload?.discussion?.number;
const commentId = payload?.comment?.id;
if (!discussionNumber || !commentId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`);
return null;
}
const commentNodeId = payload?.comment?.node_id;
if (!commentNodeId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
return null;
}
return {
reactionEndpoint: commentNodeId, // Store node ID for GraphQL
commentUpdateEndpoint: `discussion_comment:${discussionNumber}:${commentId}`, // Special format
};
}

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

async function main() {
const reaction = process.env.GH_AW_REACTION || "eyes";
const command = process.env.GH_AW_COMMAND; // Only present for command workflows
Expand All @@ -34,113 +148,20 @@ async function main() {
core.info(`Run ID: ${context.runId}`);
core.info(`Run URL: ${runUrl}`);

// Validate reaction type
const validReactions = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"];
if (!validReactions.includes(reaction)) {
core.setFailed(`${ERR_VALIDATION}: Invalid reaction type: ${reaction}. Valid reactions are: ${validReactions.join(", ")}`);
if (!VALID_REACTIONS.includes(reaction)) {
core.setFailed(`${ERR_VALIDATION}: Invalid reaction type: ${reaction}. Valid reactions are: ${VALID_REACTIONS.join(", ")}`);
return;
}

let reactionEndpoint;
let commentUpdateEndpoint;
const eventName = invocationContext.eventName;
const owner = invocationContext.eventRepo.owner;
const repo = invocationContext.eventRepo.repo;
const { owner, repo } = invocationContext.eventRepo;
const payload = invocationContext.eventPayload;

try {
switch (eventName) {
case "issues": {
const issueNumber = 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`;
commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/comments`;
break;
}

case "issue_comment": {
const commentId = payload?.comment?.id;
const issueNumberForComment = payload?.issue?.number;
if (!commentId) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return;
}
if (!issueNumberForComment) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`;
// Create new comment on the issue itself, not on the comment
commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${issueNumberForComment}/comments`;
break;
}
const endpoints = await resolveEventEndpoints(eventName, owner, repo, payload);
if (!endpoints) return;

case "pull_request": {
const prNumber = 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`;
commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/comments`;
break;
}

case "pull_request_review_comment": {
const reviewCommentId = payload?.comment?.id;
const prNumberForReviewComment = payload?.pull_request?.number;
if (!reviewCommentId) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return;
}
if (!prNumberForReviewComment) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return;
}
reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`;
// Create new comment on the PR itself (using issues endpoint since PRs are issues)
commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${prNumberForReviewComment}/comments`;
break;
}

case "discussion": {
const discussionNumber = 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
const discussion = await getDiscussionId(owner, repo, discussionNumber);
reactionEndpoint = discussion.id; // Store node ID for GraphQL
commentUpdateEndpoint = `discussion:${discussionNumber}`; // Special format to indicate discussion
break;
}

case "discussion_comment": {
const discussionCommentNumber = payload?.discussion?.number;
const discussionCommentId = payload?.comment?.id;
if (!discussionCommentNumber || !discussionCommentId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`);
return;
}
const commentNodeId = payload?.comment?.node_id;
if (!commentNodeId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
return;
}
reactionEndpoint = commentNodeId; // Store node ID for GraphQL
commentUpdateEndpoint = `discussion_comment:${discussionCommentNumber}:${discussionCommentId}`; // Special format
break;
}

default:
core.setFailed(`${ERR_VALIDATION}: Unsupported event type: ${eventName}`);
return;
}
const { reactionEndpoint, commentUpdateEndpoint } = endpoints;

core.info(`Reaction API endpoint: ${reactionEndpoint}`);

Expand All @@ -151,10 +172,8 @@ async function main() {
await addReaction(reactionEndpoint, reaction);
}

if (commentUpdateEndpoint) {
core.info(`Comment endpoint: ${commentUpdateEndpoint}`);
await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName, invocationContext);
}
core.info(`Comment endpoint: ${commentUpdateEndpoint}`);
await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName, invocationContext);
} catch (error) {
if (isLockedError(error)) {
core.info(`Cannot add reaction: resource is locked (this is expected and not an error)`);
Expand Down Expand Up @@ -319,4 +338,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
}
}

module.exports = { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction };
module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction };
88 changes: 86 additions & 2 deletions actions/setup/js/add_reaction_and_edit_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ global.context = mockContext;

// Helper to import the module fresh (bust module cache)
async function loadModule() {
const { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction } = await import("./add_reaction_and_edit_comment.cjs?" + Date.now());
return { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction };
const { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction, resolveEventEndpoints, VALID_REACTIONS } = await import("./add_reaction_and_edit_comment.cjs?" + Date.now());
return { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction, resolveEventEndpoints, VALID_REACTIONS };
}

describe("add_reaction_and_edit_comment.cjs", () => {
Expand Down Expand Up @@ -609,4 +609,88 @@ describe("add_reaction_and_edit_comment.cjs", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "");
});
});

describe("VALID_REACTIONS", () => {
it("should export the list of valid reaction types", async () => {
const { VALID_REACTIONS } = await loadModule();
expect(VALID_REACTIONS).toEqual(["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]);
});
});

describe("resolveEventEndpoints()", () => {
it("should resolve endpoints for issues event", async () => {
const { resolveEventEndpoints } = await loadModule();
const payload = { issue: { number: 42 } };
const result = await resolveEventEndpoints("issues", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "/repos/owner/repo/issues/42/reactions",
commentUpdateEndpoint: "/repos/owner/repo/issues/42/comments",
});
});

it("should return null and call setFailed when issue number is missing", async () => {
const { resolveEventEndpoints } = await loadModule();
const result = await resolveEventEndpoints("issues", "owner", "repo", {});
expect(result).toBeNull();
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(ERR_NOT_FOUND));
});

it("should resolve endpoints for pull_request event", async () => {
const { resolveEventEndpoints } = await loadModule();
const payload = { pull_request: { number: 7 } };
const result = await resolveEventEndpoints("pull_request", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "/repos/owner/repo/issues/7/reactions",
commentUpdateEndpoint: "/repos/owner/repo/issues/7/comments",
});
});

it("should resolve endpoints for issue_comment event", async () => {
const { resolveEventEndpoints } = await loadModule();
const payload = { comment: { id: 55 }, issue: { number: 10 } };
const result = await resolveEventEndpoints("issue_comment", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "/repos/owner/repo/issues/comments/55/reactions",
commentUpdateEndpoint: "/repos/owner/repo/issues/10/comments",
});
});

it("should resolve endpoints for pull_request_review_comment event", async () => {
const { resolveEventEndpoints } = await loadModule();
const payload = { comment: { id: 99 }, pull_request: { number: 3 } };
const result = await resolveEventEndpoints("pull_request_review_comment", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "/repos/owner/repo/pulls/comments/99/reactions",
commentUpdateEndpoint: "/repos/owner/repo/issues/3/comments",
});
});

it("should resolve endpoints for discussion event using GraphQL node ID", async () => {
mockGithub.graphql.mockResolvedValueOnce({ repository: { discussion: { id: "D_node123", url: "https://github.com/testowner/testrepo/discussions/5" } } });
const { resolveEventEndpoints } = await loadModule();
const payload = { discussion: { number: 5 } };
const result = await resolveEventEndpoints("discussion", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "D_node123",
commentUpdateEndpoint: "discussion:5",
});
});

it("should resolve endpoints for discussion_comment event", async () => {
const { resolveEventEndpoints } = await loadModule();
const payload = { discussion: { number: 5 }, comment: { id: 88, node_id: "DC_node88" } };
const result = await resolveEventEndpoints("discussion_comment", "owner", "repo", payload);
expect(result).toEqual({
reactionEndpoint: "DC_node88",
commentUpdateEndpoint: "discussion_comment:5:88",
});
});

it("should return null and call setFailed for unknown event type", async () => {
const { resolveEventEndpoints } = await loadModule();
const result = await resolveEventEndpoints("push", "owner", "repo", {});
expect(result).toBeNull();
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(ERR_VALIDATION));
});
});
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/zoom-out] The new resolveEventEndpoints() tests cover happy paths and the most common failure cases, but several error branches remain untested:

  • issue_comment with missing commentIdERR_VALIDATION
  • issue_comment with missing issueNumberERR_NOT_FOUND
  • pull_request_review_comment with missing reviewCommentId or prNumber
  • discussion with missing discussionNumber
  • discussion_comment with missing node_id

Since the stated goal of extracting this function is to make the logic independently testable, covering these failure paths would complete the picture:

it('returns null and calls setFailed when issue_comment is missing commentId', async () => {
  const { resolveEventEndpoints } = await loadModule();
  const result = await resolveEventEndpoints('issue_comment', 'owner', 'repo', { issue: { number: 1 } });
  expect(result).toBeNull();
  expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(ERR_VALIDATION));
});