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;
}
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));
});
});
});
4 changes: 2 additions & 2 deletions docs/src/content/docs/examples/scheduled.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
title: Scheduled Workflows
description: Workflows that run automatically on a schedule using cron expressions - daily reports, weekly research, and continuous improvement patterns
description: Workflows that run automatically on a schedule using fuzzy schedules - daily reports, weekly research, and continuous improvement patterns
sidebar:
order: 1
---

Scheduled workflows run automatically at specified times using cron expressions. They're perfect for recurring tasks like daily status updates, weekly research reports, continuous code improvements, and automated maintenance.
Scheduled workflows run automatically at specified times using fuzzy schedule expressions. They're perfect for recurring tasks like daily status updates, weekly research reports, continuous code improvements, and automated maintenance.

## When to Use Scheduled Workflows

Expand Down
3 changes: 1 addition & 2 deletions docs/src/content/docs/patterns/batch-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ Split work into fixed-size pages using `GITHUB_RUN_NUMBER`. Each run processes o
```aw wrap
---
on:
schedule:
- cron: "0 2 * * 1-5" # Weekdays at 2 AM
schedule: daily on weekdays
workflow_dispatch:

tools:
Expand Down
Loading
Loading