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
2 changes: 1 addition & 1 deletion .github/workflows/agentic-auto-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade

on:
schedule:
- cron: "21 3 * * 5" # Weekly (auto-upgrade)
- cron: "11 4 * * 6" # Weekly (auto-upgrade)
workflow_dispatch:

permissions:
Expand Down
26 changes: 14 additions & 12 deletions actions/setup/js/add_reaction.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function main() {
const { owner, repo } = invocationContext.eventRepo;
const payload = invocationContext.eventPayload;

/** @type {string | null} */
/** @type {{ route: string, params: Record<string, unknown> } | null} */
const reactionEndpoint = resolveRestEndpoint(eventName, owner, repo, payload);

if (reactionEndpoint === null) {
Expand All @@ -52,22 +52,22 @@ async function main() {
return;
}

core.info(`Adding reaction to: ${reactionEndpoint}`);
core.info(`Adding reaction to: ${reactionEndpoint.route}`);
try {
await addReaction(reactionEndpoint, reaction);
await addReaction(reactionEndpoint.route, reactionEndpoint.params, reaction);
} catch (error) {
handleReactionError(error);
}
}

/**
* Resolve the REST API endpoint for non-discussion events.
* Resolve the REST API route and params for non-discussion events.
* Returns null for discussion/discussion_comment/pull_request_review/unsupported events (handled separately).
* @param {string} eventName
* @param {string} owner
* @param {string} repo
* @param {Record<string, any>} payload
* @returns {string | null}
* @returns {{ route: string, params: Record<string, unknown> } | null}
*/
function resolveRestEndpoint(eventName, owner, repo, payload) {
switch (eventName) {
Expand All @@ -77,7 +77,7 @@ function resolveRestEndpoint(eventName, owner, repo, payload) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return null;
}
return `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`;
return { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: issueNumber } };
}

case "issue_comment": {
Expand All @@ -86,7 +86,7 @@ function resolveRestEndpoint(eventName, owner, repo, payload) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return null;
}
return `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`;
return { route: "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", params: { owner, repo, comment_id: commentId } };
}

case "pull_request": {
Expand All @@ -96,7 +96,7 @@ function resolveRestEndpoint(eventName, owner, repo, payload) {
return null;
}
// PRs are "issues" for the reactions endpoint
return `/repos/${owner}/${repo}/issues/${prNumber}/reactions`;
return { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: prNumber } };
}

case "pull_request_review_comment": {
Expand All @@ -105,7 +105,7 @@ function resolveRestEndpoint(eventName, owner, repo, payload) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return null;
}
return `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`;
return { route: "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions", params: { owner, repo, comment_id: reviewCommentId } };
}

case "pull_request_review":
Expand Down Expand Up @@ -186,11 +186,13 @@ function handleReactionError(error) {

/**
* Add a reaction to a GitHub issue, PR, or comment using REST API
* @param {string} endpoint - The GitHub API endpoint to add the reaction to
* @param {string} route - The typed GitHub API route string (e.g. "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions")
* @param {Record<string, unknown>} params - The route parameters (owner, repo, issue_number, etc.)
* @param {string} reaction - The reaction type to add
*/
async function addReaction(endpoint, reaction) {
const response = await github.request(`POST ${endpoint}`, {
async function addReaction(route, params, reaction) {
const response = await github.request(route, {
...params,
content: reaction,
headers: {
Accept: "application/vnd.github+json",
Expand Down
29 changes: 16 additions & 13 deletions actions/setup/js/add_reaction.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe("add_reaction", () => {

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/reactions", expect.objectContaining({ content: "eyes" }));
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", expect.objectContaining({ content: "eyes", owner: "testowner", repo: "testrepo", issue_number: 456 }));
expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "12345");
});

Expand Down Expand Up @@ -180,7 +180,7 @@ describe("add_reaction", () => {

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/comments/789/reactions", expect.objectContaining({ content: "eyes" }));
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", expect.objectContaining({ content: "eyes", owner: "testowner", repo: "testrepo", comment_id: 789 }));
});

it("should fail when comment ID is missing", async () => {
Expand All @@ -206,7 +206,7 @@ describe("add_reaction", () => {

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/999/reactions", expect.objectContaining({ content: "eyes" }));
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", expect.objectContaining({ content: "eyes", owner: "testowner", repo: "testrepo", issue_number: 999 }));
});

it("should fail when PR number is missing", async () => {
Expand All @@ -232,7 +232,7 @@ describe("add_reaction", () => {

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/pulls/comments/555/reactions", expect.objectContaining({ content: "eyes" }));
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions", expect.objectContaining({ content: "eyes", owner: "testowner", repo: "testrepo", comment_id: 555 }));
});

it("should fail when review comment ID is missing", async () => {
Expand Down Expand Up @@ -464,7 +464,7 @@ describe("add_reaction", () => {

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/target-owner/target-repo/issues/comments/456/reactions", expect.objectContaining({ content: "eyes" }));
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", expect.objectContaining({ content: "eyes", owner: "target-owner", repo: "target-repo", comment_id: 456 }));
});

it("should fail for unsupported event types", async () => {
Expand Down Expand Up @@ -590,9 +590,12 @@ describe("add_reaction", () => {
const { addReaction } = await importHelpers();
mockGithub.request.mockResolvedValueOnce({ data: { id: 111 } });

await addReaction("/repos/owner/repo/issues/1/reactions", "+1");
await addReaction("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", { owner: "owner", repo: "repo", issue_number: 1 }, "+1");

expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/owner/repo/issues/1/reactions", {
expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", {
owner: "owner",
repo: "repo",
issue_number: 1,
content: "+1",
headers: { Accept: "application/vnd.github+json" },
});
Expand All @@ -603,7 +606,7 @@ describe("add_reaction", () => {
const { addReaction } = await importHelpers();
mockGithub.request.mockResolvedValueOnce({ data: {} });

await addReaction("/repos/owner/repo/issues/1/reactions", "eyes");
await addReaction("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", { owner: "owner", repo: "repo", issue_number: 1 }, "eyes");

expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "");
});
Expand All @@ -612,7 +615,7 @@ describe("add_reaction", () => {
const { addReaction } = await importHelpers();
mockGithub.request.mockResolvedValueOnce({ data: { id: 777 } });

await addReaction("/repos/owner/repo/issues/1/reactions", "rocket");
await addReaction("POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", { owner: "owner", repo: "repo", issue_number: 1 }, "rocket");

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("rocket"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("777"));
Expand Down Expand Up @@ -694,7 +697,7 @@ describe("add_reaction", () => {
it("should return issues endpoint", async () => {
global.context = { eventName: "issues", repo: { owner: "o", repo: "r" }, payload: { issue: { number: 1 } } };
const { resolveRestEndpoint } = await importHelpers();
expect(resolveRestEndpoint("issues", "o", "r", global.context.payload)).toBe("/repos/o/r/issues/1/reactions");
expect(resolveRestEndpoint("issues", "o", "r", global.context.payload)).toEqual({ route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner: "o", repo: "r", issue_number: 1 } });
});

it("should return null and setFailed when issue number is missing", async () => {
Expand All @@ -707,7 +710,7 @@ describe("add_reaction", () => {
it("should return issue_comment endpoint", async () => {
global.context = { eventName: "issue_comment", repo: { owner: "o", repo: "r" }, payload: { comment: { id: 42 } } };
const { resolveRestEndpoint } = await importHelpers();
expect(resolveRestEndpoint("issue_comment", "o", "r", global.context.payload)).toBe("/repos/o/r/issues/comments/42/reactions");
expect(resolveRestEndpoint("issue_comment", "o", "r", global.context.payload)).toEqual({ route: "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", params: { owner: "o", repo: "r", comment_id: 42 } });
});

it("should return null and setFailed when comment id is missing", async () => {
Expand All @@ -720,7 +723,7 @@ describe("add_reaction", () => {
it("should return pull_request endpoint using issues path", async () => {
global.context = { eventName: "pull_request", repo: { owner: "o", repo: "r" }, payload: { pull_request: { number: 7 } } };
const { resolveRestEndpoint } = await importHelpers();
expect(resolveRestEndpoint("pull_request", "o", "r", global.context.payload)).toBe("/repos/o/r/issues/7/reactions");
expect(resolveRestEndpoint("pull_request", "o", "r", global.context.payload)).toEqual({ route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner: "o", repo: "r", issue_number: 7 } });
});

it("should return null and setFailed when PR number is missing", async () => {
Expand All @@ -733,7 +736,7 @@ describe("add_reaction", () => {
it("should return pull_request_review_comment endpoint", async () => {
global.context = { eventName: "pull_request_review_comment", repo: { owner: "o", repo: "r" }, payload: { comment: { id: 55 } } };
const { resolveRestEndpoint } = await importHelpers();
expect(resolveRestEndpoint("pull_request_review_comment", "o", "r", global.context.payload)).toBe("/repos/o/r/pulls/comments/55/reactions");
expect(resolveRestEndpoint("pull_request_review_comment", "o", "r", global.context.payload)).toEqual({ route: "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions", params: { owner: "o", repo: "r", comment_id: 55 } });
});

it("should return null without failure for pull_request_review events", async () => {
Expand Down
68 changes: 51 additions & 17 deletions actions/setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,39 @@ const EVENT_TYPE_DESCRIPTIONS = {
/** Valid GitHub reaction types */
const VALID_REACTIONS = Object.freeze(Object.keys(REACTION_MAP));

/**
* @typedef {{ route: string, params: Record<string, unknown> }} RestEndpoint
*/

/**
* @param {unknown} endpoint
* @returns {endpoint is RestEndpoint}
*/
function isRestEndpoint(endpoint) {
return typeof endpoint === "object" && endpoint !== null && "route" in endpoint && "params" in endpoint;
}

/**
* @param {unknown} endpoint
* @param {string} endpointName
* @param {string} eventName
* @returns {RestEndpoint}
*/
function expectRestEndpoint(endpoint, endpointName, eventName) {
if (!isRestEndpoint(endpoint)) {
throw new Error(`${ERR_VALIDATION}: Unexpected ${endpointName} endpoint shape for event: ${eventName}`);
}
return endpoint;
}

/**
* 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>}
* @returns {Promise<{reactionEndpoint: { route: string, params: Record<string, unknown> } | string, commentUpdateEndpoint: { route: string, params: Record<string, unknown> } | string} | null>}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] The union return type { route: string, params: ... } | string leaks the discussion-special-case format into the type signature, causing defensive typeof endpoint === 'object' checks at every call site. Consider a tagged union to make the branching explicit and self-documenting.

💡 Suggested typed shape
// `@typedef` {{ kind: 'rest', route: string, params: Record<string, unknown> }} RestEndpoint
// `@typedef` {{ kind: 'discussion', nodeRef: string }} DiscussionEndpoint
// `@typedef` {RestEndpoint | DiscussionEndpoint} EventEndpoint

Call sites can then switch (endpoint.kind) instead of typeof checks.

@copilot please address this.

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.

Addressed in 3bba3d7 (with cleanup in 90c2fe7). I added explicit runtime endpoint-shape guards (expectRestEndpoint) so we no longer rely on unchecked casts. This avoids silent destructuring failures and gives a clear validation error if endpoint shape regresses.

*/
async function resolveEventEndpoints(eventName, owner, repo, payload) {
switch (eventName) {
Expand All @@ -45,8 +70,8 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
return null;
}
return {
reactionEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`,
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: issueNumber } },
commentUpdateEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", params: { owner, repo, issue_number: issueNumber } },
};
}

Expand All @@ -62,9 +87,9 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
return null;
}
return {
reactionEndpoint: `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", params: { owner, repo, comment_id: commentId } },
// Create new comment on the issue itself, not on the comment
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
commentUpdateEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", params: { owner, repo, issue_number: issueNumber } },
};
}

Expand All @@ -76,8 +101,8 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
}
// PRs are "issues" for the reactions endpoint
return {
reactionEndpoint: `/repos/${owner}/${repo}/issues/${prNumber}/reactions`,
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${prNumber}/comments`,
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: prNumber } },
commentUpdateEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", params: { owner, repo, issue_number: prNumber } },
};
}

Expand All @@ -93,9 +118,9 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
return null;
}
return {
reactionEndpoint: `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`,
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions", params: { owner, repo, comment_id: reviewCommentId } },
// Create new comment on the PR itself (using issues endpoint since PRs are issues)
commentUpdateEndpoint: `/repos/${owner}/${repo}/issues/${prNumber}/comments`,
commentUpdateEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", params: { owner, repo, issue_number: prNumber } },
};
}

Expand Down Expand Up @@ -171,16 +196,20 @@ async function main() {

const { reactionEndpoint, commentUpdateEndpoint } = endpoints;

core.info(`Reaction API endpoint: ${reactionEndpoint}`);
core.info(`Reaction API endpoint: ${typeof reactionEndpoint === "object" ? reactionEndpoint.route : reactionEndpoint}`);

// For discussions, reactionEndpoint is a node ID (GraphQL), otherwise it's a REST API path
// For discussions, reactionEndpoint is a node ID (GraphQL), otherwise it's a typed REST route
if (eventName === "discussion" || eventName === "discussion_comment") {
if (typeof reactionEndpoint !== "string") {
throw new Error(`${ERR_VALIDATION}: Unexpected reaction endpoint shape for event: ${eventName}`);
}
await addDiscussionReaction(reactionEndpoint, reaction);
} else {
await addReaction(reactionEndpoint, reaction);
const { route, params } = expectRestEndpoint(reactionEndpoint, "reaction", eventName);
await addReaction(route, params, reaction);
}

core.info(`Comment endpoint: ${commentUpdateEndpoint}`);
core.info(`Comment endpoint: ${typeof commentUpdateEndpoint === "object" ? commentUpdateEndpoint.route : commentUpdateEndpoint}`);
await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName, invocationContext);
} catch (error) {
if (isLockedError(error)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The addReaction branch casts reactionEndpoint with /** @type {...} */ but no test covers the code path where reactionEndpoint is unexpectedly a plain string (e.g. from a future event type regression). Adding a test that passes a string here would catch silent destructuring failures early.

@copilot please address this.

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.

Addressed in 3bba3d7. I added a regression test that exercises the unexpected string endpoint path via expectRestEndpoint(...) and verifies it throws a validation error instead of failing later during destructuring.

Expand Down Expand Up @@ -211,7 +240,7 @@ function setCommentOutputs(commentId, commentUrl, eventRepo = context.repo) {

/**
* Add a comment with a workflow run link
* @param {string} endpoint - The GitHub API endpoint to create the comment (or special format for discussions)
* @param {{ route: string, params: Record<string, unknown> } | string} endpoint - Typed route info for REST events, or special format string for discussions
* @param {string} runUrl - The URL of the workflow run
* @param {string} eventName - The event type (to determine the comment text)
* @param {{
Expand Down Expand Up @@ -251,6 +280,9 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
const commentBody = commentParts.join("\n\n");

if (eventName === "discussion" || eventName === "discussion_comment") {
if (typeof endpoint !== "string") {
throw new Error(`${ERR_VALIDATION}: Unexpected comment endpoint shape for event: ${eventName}`);
}
// Parse discussion number from special format: "discussion:NUMBER" or "discussion_comment:NUMBER:COMMENT_ID"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
const discussionId = await getDiscussionNodeId(eventRepo.owner, eventRepo.repo, discussionNumber);
Expand All @@ -262,8 +294,10 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
return;
}

// Create a new comment for non-discussion events
const createResponse = await github.request(`POST ${endpoint}`, {
// Create a new comment for non-discussion events using typed route
const { route, params } = expectRestEndpoint(endpoint, "comment", eventName);
const createResponse = await github.request(route, {
...params,
body: commentBody,
headers: {
Accept: "application/vnd.github+json",
Expand All @@ -278,4 +312,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
}
}

module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction };
module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction, expectRestEndpoint };
Loading
Loading