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
26 changes: 22 additions & 4 deletions actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,18 @@ function reportCommentError(rawContext, message) {
core.setFailed(message);
}

/**
* @param {Record<string, any>|null} awContext
* @param {string} key
* @returns {string}
*/
function readAwContextString(awContext, key) {
if (!awContext || typeof awContext[key] !== "string") {
return "";
}
return awContext[key].trim();
}

/**
* @param {ReusableStatusComment} reusableComment
* @param {{
Expand All @@ -184,8 +196,11 @@ function reportCommentError(rawContext, message) {
* @returns {Promise<string>}
*/
async function updateReusableStatusComment(reusableComment, invocationContext, rawContext) {
const runUrl = buildWorkflowRunUrl(rawContext, invocationContext.workflowRepo);
const commentBody = buildCommentBody(invocationContext.eventName, runUrl);
const awContext = extractAwContextFromPayload(rawContext?.payload);
const dispatchedRunUrl = readAwContextString(awContext, "dispatched_run_url");
const dispatchedWorkflowName = readAwContextString(awContext, "dispatched_workflow_name");
const runUrl = dispatchedRunUrl || buildWorkflowRunUrl(rawContext, invocationContext.workflowRepo);
const commentBody = buildCommentBody(invocationContext.eventName, runUrl, dispatchedWorkflowName || undefined);

// Discussion comments use GraphQL node IDs and a dedicated update mutation.
if (reusableComment.id.startsWith("DC_")) {
Expand Down Expand Up @@ -337,10 +352,13 @@ async function main() {
* 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} [workflowNameOverride] - Optional dispatched workflow name override
* @returns {string} The assembled comment body
*/
function buildCommentBody(eventName, runUrl) {
const workflowName = process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow";
function buildCommentBody(eventName, runUrl, workflowNameOverride) {
// Whitespace-only overrides are treated as absent and fall back to env defaults.
const normalizedWorkflowNameOverride = workflowNameOverride?.trim();
const workflowName = normalizedWorkflowNameOverride || process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "Workflow";

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.

Visible workflow name and hidden workflow-ID marker are now out of sync: workflowName (user-visible) correctly uses workflowNameOverride, but the generateWorkflowIdMarker call a few lines below still uses process.env.GITHUB_WORKFLOW (the router's name). When the dispatched workflow archie.lock.yml later runs its own add_workflow_run_comment step, it searches for an existing status comment tagged with marker archie — but the comment was tagged with the router's marker (e.g., agentic-commands). The match fails, a second status comment is created, and the original router-enriched comment is orphaned.

💡 Suggested fix

Pass the workflow name override through to the marker as well:

const workflowId = workflowNameOverride || process.env.GH_AW_WORKFLOW_NAME || process.env.GITHUB_WORKFLOW || "";
if (workflowId) {
  body += `\n\n${generateWorkflowIdMarker(workflowId)}`;
}

Alternatively, keep the router marker intentionally and document that the reuse contract relies on status_comment_id rather than marker matching — but the current code makes this implicit and fragile.

const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event";

// Sanitize before adding markers (defense in depth for custom message templates)
Expand Down
40 changes: 40 additions & 0 deletions actions/setup/js/add_workflow_run_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,38 @@ describe("add_workflow_run_comment", () => {
expect(mockCore.setFailed).not.toHaveBeenCalled();
});

it("uses dispatched workflow metadata from aw_context when provided", async () => {
global.context = {
eventName: "workflow_dispatch",
runId: 12345,
repo: { owner: "workflowowner", repo: "workflowrepo" },
payload: {
inputs: {
aw_context: JSON.stringify({
repo: "targetowner/targetrepo",
event_type: "issue_comment",
item_type: "issue",
item_number: 789,
status_comment_id: 67890,
status_comment_url: "https://github.com/targetowner/targetrepo/issues/789#issuecomment-67890",
dispatched_workflow_name: "archie",
dispatched_run_url: "https://github.com/github/gh-aw/actions/runs/444",
}),
},
},
};

await runScript();

expect(mockGithub.request).toHaveBeenCalledWith(
"PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}",
expect.objectContaining({
comment_id: 67890,
body: expect.stringContaining("[archie](https://github.com/github/gh-aw/actions/runs/444)"),
})
);
});

it("updates reusable centralized slash-command discussion comments via GraphQL", async () => {
mockGithub.graphql.mockResolvedValue({
updateDiscussionComment: {
Expand Down Expand Up @@ -909,6 +941,14 @@ describe("add_workflow_run_comment", () => {
expect(body).toContain("[Agentic Commands]");
});

it("should prefer explicit workflow name override over environment defaults", async () => {
process.env.GH_AW_WORKFLOW_NAME = "Agentic Commands";
const { buildCommentBody } = await importAddWorkflowRunComment();
const body = buildCommentBody("issue_comment", "https://example.com/run/1", "archie");
expect(body).toContain("[archie]");
expect(body).not.toContain("[Agentic Commands]");
});

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 importAddWorkflowRunComment();
Expand Down
104 changes: 97 additions & 7 deletions actions/setup/js/route_slash_command.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -295,16 +295,29 @@ async function addImmediateStatusComment() {
}
}

/**
* @param {Array<unknown>} values
* @returns {string}
*/
function firstNonEmptyString(values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value;
}
}
return "";
}

/**
* Dispatches a workflow with the API version header required by GitHub REST.
* @param {string} workflowId
* @param {string} ref
* @param {Record<string, string>} inputs
* @returns {Promise<boolean>}
* @returns {Promise<{ dispatched: boolean, run_id?: number, run_url?: string }>}
*/
async function dispatchWorkflow(workflowId, ref, inputs) {
try {
await github.rest.actions.createWorkflowDispatch({
const dispatchArgs = {
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
Expand All @@ -313,17 +326,91 @@ async function dispatchWorkflow(workflowId, ref, inputs) {
headers: {
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
});
return true;
};

/** @type {{ data?: any }} */
let response;
try {
response = await github.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
...dispatchArgs,
return_run_details: true,
});
} catch (dispatchError) {
/** @type {any} */
const err = dispatchError;
const status = err && typeof err === "object" ? err.status : undefined;
const message = typeof err?.response?.data?.message === "string" ? err.response.data.message : String(dispatchError);
const isValidationStatus = status === 400 || status === 422;
const mentionsReturnRunDetails = typeof message === "string" && message.toLowerCase().includes("return_run_details");

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.

return_run_details detection relies on a fragile substring match that will silently hard-fail dispatch on GHES with different error phrasing: the inner catch only retries when the error is both a 400/422 status AND its message contains the literal string "return_run_details". If a GHES version rejects the parameter with a different message (e.g., "unexpected_input", "invalid field", etc.), mentionsReturnRunDetails is false, the inner catch re-throws, and the outer catch turns it into a hard Failed to dispatch workflow failure instead of the intended silent retry.

💡 Suggested fix

Retry on all 400/422 errors — the worst case from a false positive is an unnecessary second request. Since return_run_details is the only added field and valid GHES endpoints won't reject a vanilla dispatch, retrying without it on any 4xx is safe:

const isValidationStatus = status === 400 || status === 422;
if (!isValidationStatus) {
  throw err;
}
core.info("Workflow dispatch endpoint returned 4xx; retrying without return_run_details (GHES compatibility fallback).");
response = await github.request(...);

This is strictly more robust and no more dangerous than the current implementation.

if (!(isValidationStatus && mentionsReturnRunDetails)) {
throw err;
}
core.info("Workflow dispatch endpoint rejected 'return_run_details' (common on older GHES versions); retrying without it.");
response = await github.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", dispatchArgs);

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.

The return_run_details compatibility fallback (retrying without that parameter) has no dedicated test coverage. Consider adding a test that mocks github.request to throw a 422 error whose message includes "return_run_details" on the first call, then succeeds on the second — verifying the dispatch still completes and dispatched.dispatched is true.

@copilot please address this.

}

const responseData = response?.data || {};
// GitHub may return run metadata in either shape:
// - flat: { workflow_run_id, workflow_run_url } (workflow_run_url is a REST API URL)
// - nested: { workflow_run: { id, html_url, url } } (url is a REST API URL; html_url is the Actions page URL)
const parsedRunId = Number(responseData?.workflow_run_id ?? responseData?.workflow_run?.id);
const runId = Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined;
const serverUrl = context.serverUrl || process.env.GITHUB_SERVER_URL || "https://github.com";
// Prefer the constructed HTML URL when runId is available — it is always the Actions run page URL.
// Avoid workflow_run_url and workflow_run.url which are REST API URLs, not Actions run page URLs.
const runUrlFromRunId = runId ? `${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}` : undefined;
const runUrlFromResponse = firstNonEmptyString([responseData?.workflow_run?.html_url]);
const runUrl = runUrlFromRunId || runUrlFromResponse;
return {
dispatched: true,
...(runId ? { run_id: runId } : {}),
...(runUrl ? { run_url: runUrl } : {}),
};
} catch (error) {
if (isDisabledWorkflowDispatchError(error)) {
core.info(`Skipping workflow '${workflowId}' because it is disabled.`);
return false;
return { dispatched: false };
}
throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`);
}
}

/**
* Update the shared status comment with dispatched workflow run metadata.
* @param {{ status_comment_id: string, status_comment_url?: string, status_comment_repo?: string }} statusCommentContext
* @param {string} eventName
* @param {string} workflowId
* @param {string|undefined} runUrl
*/
async function updateStatusCommentWithDispatch(statusCommentContext, eventName, workflowId, runUrl) {
if (!statusCommentContext?.status_comment_id) {
return;
}
if (!runUrl) {

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.

Status comment is silently left stale when the server returns no run URL (old GHES): when return_run_details is rejected and the retry response contains no run metadata, dispatched.run_url is undefined, and this guard returns early with no core.warning. The status comment created by addImmediateStatusComment() is never updated with the dispatched workflow name — the PR's primary goal — and there is no observable signal that the enrichment was skipped.

💡 Suggested improvement

Add a log so that operators can diagnose missing enrichment in GHES environments:

if (!runUrl) {
  core.info(`Skipping status comment enrichment for '${workflowId}': dispatch response contained no run URL (older GHES may not support return_run_details).`);
  return;
}

If workflow-name attribution is considered important enough to justify the effort, you could also patch the comment with just the dispatched workflow name even when runUrl is unavailable, rather than skipping the update entirely.

return;
}
try {
await createOrReuseStatusComment({
...context,
eventName: "workflow_dispatch",
payload: {
inputs: {
event_name: eventName,
event_payload: JSON.stringify(context.payload || {}),
aw_context: JSON.stringify({
...statusCommentContext,
dispatched_workflow_name: workflowId.replace(/\.lock\.yml$/, ""),
dispatched_run_url: runUrl,
}),
},
},
nonFatalStatusCommentErrors: true,
});
} catch (error) {
core.warning(`Failed to update immediate status comment with dispatched run details: ${String(error)}`);
}
}

function isBuiltinHelpEnabled() {
const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase();
if (!raw || raw === "true") {
Expand Down Expand Up @@ -602,7 +689,7 @@ async function main() {
const dispatched = await dispatchWorkflow(workflowID, ref, {
aw_context: JSON.stringify(awContext),
});
if (dispatched) {
if (dispatched.dispatched) {
core.info(`Dispatched '${workflowID}' for label '${labelName}'`);
}
}
Expand Down Expand Up @@ -669,8 +756,11 @@ async function main() {
const dispatched = await dispatchWorkflow(workflowID, ref, {
aw_context: JSON.stringify(awContext),
});
if (dispatched) {
if (dispatched.dispatched) {
core.info(`Dispatched '${workflowID}' for '/${commandName}'`);
if (maintainsStatusComment(route) && statusCommentContext) {
await updateStatusCommentWithDispatch(statusCommentContext, identifier, workflowID, dispatched.run_url);

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.

When multiple routes in the same slash command have status_comment: true, this loop will call updateStatusCommentWithDispatch once per matching route. Each call overwrites the comment with the latest dispatched workflow's name/URL, so only the last dispatched workflow ends up displayed. This is probably fine for single-route configurations (the common case), but if multi-workflow slash commands with status_comment: true are expected, the behavior should be documented or the last-write-wins semantic should be made explicit.

@copilot please address this.

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.

Status comment stomped on each loop iteration in fan-out scenarios: updateStatusCommentWithDispatch is called inside the routes loop, so when multiple routes both maintainsStatusComment, every successful dispatch overwrites the same comment — the last writer wins and all earlier dispatch metadata is silently lost.

💡 Impact and suggested fix

In the existing test creates an immediate status comment once and forwards it in aw_context, two routes match /archie (both status_comment: true). Both dispatches succeed, so updateStatusCommentWithDispatch is called twice with the same status_comment_id. The test only asserts statusUpdateCalls[0], so the second overwrite goes unobserved. In production, the comment would show only the last dispatched workflow's run link.

Options:

  1. Collect { workflowId, runUrl } per-route and perform a single comment update after the loop.
  2. Skip the router-side update for multi-route fans and let each dispatched workflow's own add_workflow_run_comment step handle attribution.
  3. At minimum, tighten the assertion: verify the final comment state after both dispatches, not just statusUpdateCalls[0].

}
}
}
core.info(`Completed centralized routing for '/${commandName}'.`);
Expand Down
45 changes: 36 additions & 9 deletions actions/setup/js/route_slash_command.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,17 @@ describe("route_slash_command", () => {
summary: summaryMock,
};
globals.github = {
request: vi.fn(async (...args) => {
reactionCalls.push(args);
request: vi.fn(async (route, params) => {
if (String(route).includes("/dispatches")) {
dispatchCalls.push(params);
return {
data: {
workflow_run_id: 123456,
workflow_run_url: "https://github.com/github/gh-aw/actions/runs/123456",
},
};
}
reactionCalls.push([route, params]);
return { data: { id: 1 } };
}),
graphql: vi.fn(async () => ({ repository: { discussion: { id: "D_node" } }, addReaction: { reaction: { id: "R_1" } } })),
Expand All @@ -114,9 +123,7 @@ describe("route_slash_command", () => {
],
},
})),
createWorkflowDispatch: vi.fn(async params => {
dispatchCalls.push(params);
}),
createWorkflowDispatch: vi.fn(),
},
pulls: {
get: vi.fn(async ({ pull_number }) => ({
Expand Down Expand Up @@ -216,6 +223,15 @@ describe("route_slash_command", () => {
},
};
}
if (String(route).includes("/dispatches")) {
dispatchCalls.push(params);
return {
data: {
workflow_run_id: 444,
workflow_run_url: "https://github.com/github/gh-aw/actions/runs/444",
},
};
}
reactionCalls.push([route, params]);
return { data: { id: 1 } };
});
Expand All @@ -230,6 +246,9 @@ describe("route_slash_command", () => {
expect(JSON.parse(dispatchCalls[1].inputs.aw_context).status_comment_id).toBe("999");
expect(globals.github.request.mock.calls.filter(([route]) => String(route).includes("/reactions"))).toHaveLength(1);
expect(globals.github.request.mock.calls.filter(([route]) => /\/issues\/77\/comments$/.test(String(route)))).toHaveLength(1);
const statusUpdateCalls = globals.github.request.mock.calls.filter(([route]) => String(route).startsWith("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}"));
expect(statusUpdateCalls.length).toBeGreaterThan(0);
expect(statusUpdateCalls[0][1].body).toContain("[archie](https://github.com/github/gh-aw/actions/runs/444)");

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.

No test covers the return_run_details fallback retry path: the new GHES compatibility code (inner try/catch, retry without the parameter) has zero coverage. This is the critical path for older GHES compatibility and it's entirely untested.

💡 Missing test case

Add a test that:

  1. Mocks github.request to throw { status: 422, response: { data: { message: "Unsupported parameter return_run_details" } } } on the first dispatch call.
  2. Returns { data: {} } on the second dispatch call.
  3. Asserts that dispatchCalls has exactly 2 entries, the second of which has no return_run_details field.
  4. Asserts that the dispatch is still reported as successful (no setFailed).
  5. Asserts that updateStatusCommentWithDispatch is NOT called (since the retry response has no run URL).

Without this test, a refactor that breaks the fallback will go undetected until a production GHES deployment fails.

expect(globals.github.request).toHaveBeenCalledWith(
expect.stringContaining("/issues/77/comments"),
expect.objectContaining({
Expand Down Expand Up @@ -260,10 +279,14 @@ describe("route_slash_command", () => {
});
globals.context.payload.issue.number = 77;
globals.context.payload.comment.body = "/archie please";
globals.github.request = vi.fn(async route => {
globals.github.request = vi.fn(async (route, params) => {
if (String(route).includes("/comments")) {
throw new Error("comment API down");
}
if (String(route).includes("/dispatches")) {
dispatchCalls.push(params);
return { data: {} };
}
reactionCalls.push([route]);
return { data: { id: 1 } };
});
Expand Down Expand Up @@ -628,7 +651,7 @@ describe("route_slash_command", () => {
});

it("skips slash routes when target workflow is disabled", async () => {
globals.github.rest.actions.createWorkflowDispatch = vi.fn(async () => {
globals.github.request = vi.fn(async () => {
throw Object.assign(new Error("Workflow was disabled"), {
status: 422,
response: { status: 422, data: { message: "Workflow was disabled" } },
Expand All @@ -644,7 +667,7 @@ describe("route_slash_command", () => {
});

it("skips label routes when target workflow is disabled", async () => {
globals.github.rest.actions.createWorkflowDispatch = vi.fn(async () => {
globals.github.request = vi.fn(async () => {
throw Object.assign(new Error("Workflow is disabled"), {
status: 422,
response: { status: 422, data: { message: "Workflow is disabled" } },
Expand All @@ -668,14 +691,18 @@ describe("route_slash_command", () => {
});

it("ignores disabled workflow_dispatch failures for disabled label routes", async () => {
globals.github.rest.actions.createWorkflowDispatch = vi.fn(async params => {
globals.github.request = vi.fn(async (route, params) => {
if (!String(route).includes("/dispatches")) {
return { data: { id: 1 } };
}
if (params.workflow_id === "smoke-otel-backends.lock.yml") {
throw Object.assign(new Error("Cannot trigger a 'workflow_dispatch' on a disabled workflow"), {
status: 422,
response: { status: 422, data: { message: "Cannot trigger a 'workflow_dispatch' on a disabled workflow" } },
});
}
dispatchCalls.push(params);
return { data: {} };
});
globals.context.eventName = "pull_request";
globals.context.payload = {
Expand Down
Loading