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
162 changes: 69 additions & 93 deletions actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,44 +84,25 @@ async function main() {

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

case "issues":
case "issue_comment": {
const number = context.payload?.issue?.number;
if (!number) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return;
}
// Create new comment on the issue itself, not on the comment
commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`;
break;
}

case "pull_request": {
const number = context.payload?.pull_request?.number;
if (!number) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return;
}
commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`;
break;
}

case "pull_request":
case "pull_request_review_comment": {
const number = context.payload?.pull_request?.number;
if (!number) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return;
}
// Create new comment on the PR itself (using issues endpoint since PRs are issues)
// PRs use the issues comment endpoint
commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`;
break;
}
Expand Down Expand Up @@ -156,123 +137,118 @@ async function main() {
await addCommentWithWorkflowLink(commentEndpoint, runUrl, eventName);
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Failed to create comment: ${errorMessage}`);
// Don't fail the job - just warn since this is not critical
core.warning(`Failed to create comment with workflow link: ${errorMessage}`);
}
}

/**
* Add a comment with a workflow run link
* @param {string} endpoint - The GitHub API endpoint to create the comment (or special format for discussions)
* Build the comment body text for a workflow run link.
* Sanitizes the content and appends all required markers.
* @param {string} eventName - The event type
* @param {string} runUrl - The URL of the workflow run
* @param {string} eventName - The event type (to determine the comment text)
* @returns {string} The assembled comment body
*/
async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {
// Get workflow name from environment variable
function buildCommentBody(eventName, runUrl) {
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";

// Determine the event type description using lookup object
const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event";

// Use getRunStartedMessage for the workflow link text (supports custom messages)
const workflowLinkText = getRunStartedMessage({
workflowName: workflowName,
runUrl: runUrl,
eventType: eventTypeDescription,
});

// Sanitize the workflow link text to prevent injection attacks (defense in depth for custom message templates)
// This must happen BEFORE adding workflow markers to preserve them
let commentBody = sanitizeContent(workflowLinkText);
// Sanitize before adding markers (defense in depth for custom message templates)
let body = sanitizeContent(getRunStartedMessage({ workflowName, runUrl, eventType: eventTypeDescription }));

// Add lock notice if lock-for-agent is enabled for issues or issue_comment
const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true";
if (lockForAgent && (eventName === "issues" || eventName === "issue_comment")) {
commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.";
if (process.env.GH_AW_LOCK_FOR_AGENT === "true" && (eventName === "issues" || eventName === "issue_comment")) {
body += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.";
}

// Add workflow-id and tracker-id markers for hide-older-comments feature
// Add workflow-id marker for hide-older-comments feature
const workflowId = process.env.GITHUB_WORKFLOW || "";
const trackerId = process.env.GH_AW_TRACKER_ID || "";

// Add workflow-id marker if available
if (workflowId) {
commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`;
body += `\n\n${generateWorkflowIdMarker(workflowId)}`;
}

// Add tracker-id marker if available (for backwards compatibility)
// Add tracker-id marker for backwards compatibility
const trackerId = process.env.GH_AW_TRACKER_ID || "";
if (trackerId) {
commentBody += `\n\n<!-- gh-aw-tracker-id: ${trackerId} -->`;
body += `\n\n<!-- gh-aw-tracker-id: ${trackerId} -->`;
}

// Add comment type marker to identify this as a reaction comment
// This prevents it from being hidden by hide-older-comments
commentBody += `\n\n<!-- gh-aw-comment-type: reaction -->`;
// Identify this as a reaction comment (prevents it from being hidden by hide-older-comments)
body += `\n\n<!-- gh-aw-comment-type: reaction -->`;

// Handle discussion events specially
if (eventName === "discussion") {
// Parse discussion number from special format: "discussion:NUMBER"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
return body;
}

// Get discussion node ID using helper function
const discussionId = await getDiscussionNodeId(discussionNumber);
/**
* Post a GraphQL comment to a discussion, optionally as a threaded reply.
* @param {number} discussionNumber - The discussion number
* @param {string} commentBody - The comment body
* @param {string|null} replyToNodeId - Parent comment node ID for threading (null for top-level)
*/
async function postDiscussionComment(discussionNumber, commentBody, replyToNodeId = null) {
const discussionId = await getDiscussionNodeId(discussionNumber);

const result = await github.graphql(
/** @type {any} */
let result;
if (replyToNodeId) {
result = await github.graphql(
`
mutation($dId: ID!, $body: String!, $replyToId: ID!) {
addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
comment { id url }
}
}`,
{ dId: discussionId, body: commentBody, replyToId: replyToNodeId }
);
} else {
result = await github.graphql(
`
mutation($dId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $dId, body: $body }) {
comment {
id
url
}
comment { id url }
}
}`,
{ dId: discussionId, body: commentBody }
);
}

const comment = result.addDiscussionComment.comment;
setCommentOutputs(comment.id, comment.url);
const comment = result.addDiscussionComment.comment;
setCommentOutputs(comment.id, comment.url);
}

/**
* 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 {string} runUrl - The URL of the workflow run
* @param {string} eventName - The event type (to determine the comment text)
*/
async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {
const commentBody = buildCommentBody(eventName, runUrl);

if (eventName === "discussion") {
// Parse discussion number from special format: "discussion:NUMBER"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
await postDiscussionComment(discussionNumber, commentBody);
return;
} else if (eventName === "discussion_comment") {
}

if (eventName === "discussion_comment") {
// Parse discussion number from special format: "discussion_comment:NUMBER:COMMENT_ID"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);

// Get discussion node ID using helper function
const discussionId = await getDiscussionNodeId(discussionNumber);

// Get the comment node ID to use as the parent for threading.
// GitHub Discussions only supports two nesting levels, so if the triggering comment is
// itself a reply, we resolve the top-level parent's node ID.
// GitHub Discussions only supports two nesting levels, so resolve the top-level parent's node ID
const commentNodeId = await resolveTopLevelDiscussionCommentId(github, context.payload?.comment?.node_id);

const result = await github.graphql(
`
mutation($dId: ID!, $body: String!, $replyToId: ID!) {
addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) {
comment {
id
url
}
}
}`,
{ dId: discussionId, body: commentBody, replyToId: commentNodeId }
);

const comment = result.addDiscussionComment.comment;
setCommentOutputs(comment.id, comment.url);
await postDiscussionComment(discussionNumber, commentBody, commentNodeId);
return;
}

// Create a new comment for non-discussion events
const createResponse = await github.request("POST " + endpoint, {
body: commentBody,
headers: {
Accept: "application/vnd.github+json",
},
headers: { Accept: "application/vnd.github+json" },
});

setCommentOutputs(createResponse.data.id, createResponse.data.html_url);
}

module.exports = { main, addCommentWithWorkflowLink };
module.exports = { main, addCommentWithWorkflowLink, buildCommentBody, postDiscussionComment };
98 changes: 96 additions & 2 deletions actions/setup/js/add_workflow_run_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,20 @@ describe("add_workflow_run_comment", () => {
);
expect(mockCore.setFailed).not.toHaveBeenCalled();
});

it("should fail when PR number is missing in pull_request_review_comment event", async () => {
global.context = {
eventName: "pull_request_review_comment",
runId: 12345,
repo: { owner: "testowner", repo: "testrepo" },
payload: {},
};

await runScript();

expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
expect(mockGithub.request).not.toHaveBeenCalled();
});
});

describe("main() - discussion event", () => {
Expand Down Expand Up @@ -341,9 +355,9 @@ describe("add_workflow_run_comment", () => {

await runScript();

expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to create comment"));
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to create comment with workflow link"));
// Should NOT call setFailed - errors should only warn
// Should NOT use core.error or core.setFailed for non-critical errors
expect(mockCore.error).not.toHaveBeenCalled();
expect(mockCore.setFailed).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -475,4 +489,84 @@ describe("add_workflow_run_comment", () => {
);
});
});

describe("buildCommentBody()", () => {
it("should include the run URL in the comment body", async () => {
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99");
expect(body).toContain("https://github.com/testowner/testrepo/actions/runs/99");
});

it("should always include reaction comment type marker", async () => {
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("issues", "https://example.com/run/1");
expect(body).toContain("<!-- gh-aw-comment-type: reaction -->");
});

it("should include workflow-id marker when GITHUB_WORKFLOW is set", async () => {
process.env.GITHUB_WORKFLOW = "my-workflow.yml";
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("issues", "https://example.com/run/1");
expect(body).toContain("<!-- gh-aw-workflow-id: my-workflow.yml -->");
});

it("should include tracker-id marker when GH_AW_TRACKER_ID is set", async () => {
process.env.GH_AW_TRACKER_ID = "my-tracker";
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("issues", "https://example.com/run/1");
expect(body).toContain("<!-- gh-aw-tracker-id: my-tracker -->");
});

it("should add lock notice for issues event when GH_AW_LOCK_FOR_AGENT=true", async () => {
process.env.GH_AW_LOCK_FOR_AGENT = "true";
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("issues", "https://example.com/run/1");
expect(body).toContain("🔒 This issue has been locked");
});

it("should not add lock notice for pull_request events", async () => {
process.env.GH_AW_LOCK_FOR_AGENT = "true";
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
const body = buildCommentBody("pull_request", "https://example.com/run/1");
expect(body).not.toContain("🔒 This issue has been locked");
});

it("should use unknown event type description for unrecognized events", async () => {
const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now());
// Should not throw for unknown event types
const body = buildCommentBody("some_unknown_event", "https://example.com/run/1");
expect(body).toBeTruthy();
expect(body).toContain("<!-- gh-aw-comment-type: reaction -->");
});
});

describe("postDiscussionComment()", () => {
it("should post a top-level discussion comment when no replyToNodeId", async () => {
const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now());
await postDiscussionComment(10, "Test body");

expect(mockGithub.graphql).toHaveBeenCalled();
const mutationCall = mockGithub.graphql.mock.calls.find(call => call[0].includes("addDiscussionComment"));
expect(mutationCall).toBeDefined();
expect(mutationCall[1]).toMatchObject({ body: "Test body" });
expect(mutationCall[1]).not.toHaveProperty("replyToId");
});

it("should post a threaded comment when replyToNodeId is provided", async () => {
const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now());
await postDiscussionComment(10, "Reply body", "DC_kwParent123");

const mutationCall = mockGithub.graphql.mock.calls.find(call => call[0].includes("replyToId"));
expect(mutationCall).toBeDefined();
expect(mutationCall[1]).toMatchObject({ body: "Reply body", replyToId: "DC_kwParent123" });
});

it("should set comment outputs after posting", async () => {
const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now());
await postDiscussionComment(10, "Test body");

expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOTest456");
expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", expect.stringContaining("discussioncomment-456"));
});
});
});
Loading