Skip to content
Closed
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
121 changes: 120 additions & 1 deletion actions/setup/js/route_slash_command.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,38 @@ function normalizeDispatchRef(ref) {
return ref.startsWith("refs/") ? ref : `refs/heads/${ref}`;
}

/**
* Resolves pull request number for issue-backed PR events.
* Falls back to parsing the pull request API URL when issue.number is unavailable.
* @returns {number} Pull request number, or 0 when missing/invalid.
*/
function resolveIssueBackedPullNumber() {
const issueNumber = context.payload?.issue?.number;
if (typeof issueNumber === "number" && Number.isInteger(issueNumber) && issueNumber > 0) {
return issueNumber;
}
if (typeof issueNumber === "string" && /^\d+$/.test(issueNumber)) {
const parsed = Number(issueNumber);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
}

const pullRequestURL = context.payload?.issue?.pull_request?.url;
if (typeof pullRequestURL !== "string") {
return 0;
}
const match = pullRequestURL.match(/\/pulls\/(\d+)(?:$|[/?#])/);
if (!match) {
return 0;
}
const parsed = Number(match[1]);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
}

async function resolveIssueBackedPRHeadRef() {
const isIssueBackedPullRequest = context.payload?.issue?.pull_request;
const pullNumber = context.payload?.issue?.number;
const pullNumber = resolveIssueBackedPullNumber();
if (!isIssueBackedPullRequest || !pullNumber) {
return "";
}
Expand Down Expand Up @@ -320,10 +349,81 @@ async function dispatchWorkflow(workflowId, ref, inputs) {
core.info(`Skipping workflow '${workflowId}' because it is disabled.`);
return false;
}
if (isMissingWorkflowDispatchRefError(error)) {
const fallbackRef = await resolveDefaultBranchRef(ref);
if (!fallbackRef) {
throw new Error(`Failed to dispatch workflow '${workflowId}': ref '${ref}' was not found and no usable fallback ref (missing or identical) was available: ${String(error)}`);
}
core.warning(`Dispatch ref '${ref}' was not found for '${workflowId}'; retrying with fallback ref '${fallbackRef}'.`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
ref: fallbackRef,
inputs,
headers: {
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
});
return true;
}
throw new Error(`Failed to dispatch workflow '${workflowId}' on ref '${ref}': ${String(error)}`);
}
}

/**
* Resolves a fallback dispatch ref when the original ref is unavailable.
* Prefers the event payload default branch, then repository metadata, then main.
* Returns an empty string when no usable fallback differs from the original ref.
* @param {string} ref
* @returns {Promise<string>}
*/
async function resolveDefaultBranchRef(ref) {
const payloadDefaultBranch = context.payload?.repository?.default_branch;
if (typeof payloadDefaultBranch === "string" && payloadDefaultBranch.trim()) {
return resolveFallbackRefFromBranchName(ref, payloadDefaultBranch);
}

try {
const response = await github.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo,
headers: {
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
});
const apiDefaultBranch = response?.data?.default_branch;
if (typeof apiDefaultBranch === "string") {
return resolveFallbackRefFromBranchName(ref, apiDefaultBranch);
}
} catch (repoError) {
core.warning(`Failed to resolve default branch for dispatch fallback: ${String(repoError)}`);
}

return resolveFallbackRefFromBranchName(ref, "main");
}

/**
* Normalizes a branch name into a dispatch ref and returns it only when usable as fallback.
* @param {string} ref
* @param {string} branchName
* @returns {string}
*/
function resolveFallbackRefFromBranchName(ref, branchName) {
const defaultBranchRef = normalizeDispatchRef(branchName);
return isUsableFallbackRef(ref, defaultBranchRef) ? defaultBranchRef : "";
}

/**
* Returns true when a candidate ref can be used as a fallback for dispatch.
* @param {string} ref
* @param {string} candidateRef
* @returns {boolean}
*/
function isUsableFallbackRef(ref, candidateRef) {
return Boolean(candidateRef) && candidateRef !== ref;
}

function isBuiltinHelpEnabled() {
const raw = (process.env.GH_AW_HELP_COMMAND_ENABLED || "").trim().toLowerCase();
if (!raw || raw === "true") {
Expand Down Expand Up @@ -517,6 +617,25 @@ function isDisabledWorkflowDispatchError(error) {
return message.includes("workflow is disabled") || message.includes("workflow was disabled") || message.includes("disabled workflow");
}

/**
* Returns true when the dispatch failure indicates the requested ref does not exist.
* @param {any} error
* @returns {boolean}
*/
function isMissingWorkflowDispatchRefError(error) {
const status = error?.status ?? error?.response?.status;
const message = [error?.message, error?.response?.data?.message]
.filter(value => typeof value === "string" && value.trim())
.join(" ")
.toLowerCase();

if (status !== 422 || !message) {
return false;
}

return message.includes("no ref found for");
}

/**
* @param {Record<string, Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown, status_comment?: unknown}>>} slashRouteMap
* @param {string} actualCommand
Expand Down
44 changes: 44 additions & 0 deletions actions/setup/js/route_slash_command.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,50 @@ describe("route_slash_command", () => {
expect(dispatchCalls[0].ref).toBe("refs/heads/feature/pr-branch");
});

it("dispatches slash commands from issue comments on PRs when PR number is only in pull_request.url", async () => {
globals.context.payload.issue.pull_request = { url: "https://api.github.com/repos/github/gh-aw/pulls/42" };
globals.context.payload.comment.body = "/archie please";

await main();

expect(globals.github.rest.pulls.get).toHaveBeenCalledWith({
owner: "github",
repo: "gh-aw",
pull_number: 42,
headers: {
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
});
expect(dispatchCalls).toHaveLength(1);
expect(dispatchCalls[0].ref).toBe("refs/heads/feature/pr-branch");
});

it("retries dispatch on default branch when PR head ref is not available in target repo", async () => {
globals.context.payload.repository = { default_branch: "main" };
globals.context.payload.issue.pull_request = { url: "https://example.test/pr/1" };
globals.context.payload.issue.number = 1;
globals.context.payload.comment.body = "/archie please";
globals.github.rest.actions.createWorkflowDispatch = vi.fn(async params => {
if (params.ref === "refs/heads/feature/pr-branch") {
throw Object.assign(new Error("No ref found for: refs/heads/feature/pr-branch"), {
status: 422,
response: {
status: 422,
data: { message: "No ref found for: refs/heads/feature/pr-branch" },
},
});
}
dispatchCalls.push(params);
});

await main();

expect(globals.github.rest.actions.createWorkflowDispatch).toHaveBeenCalledTimes(2);
expect(dispatchCalls).toHaveLength(1);
expect(dispatchCalls[0].ref).toBe("refs/heads/main");
expect(globals.core.warning).toHaveBeenCalledWith(expect.stringContaining("retrying with fallback ref 'refs/heads/main'"));
});

it("does not add immediate reaction when no valid route reaction is configured", async () => {
process.env.GH_AW_SLASH_ROUTING = JSON.stringify({
archie: [{ workflow: "archie", events: ["issue_comment"], ai_reaction: "none" }],
Expand Down