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
102 changes: 81 additions & 21 deletions actions/setup/js/create_check_run.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

const { getErrorMessage } = require("./error_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { isStagedMode, resolveTarget } = require("./safe_output_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
Expand All @@ -33,6 +33,7 @@ async function main(config = {}) {
// Extract configuration
const configuredName = config.name || "";
const maxCount = config.max != null ? Number(config.max) : 1;
const checkRunTarget = typeof config.target === "string" && config.target.trim() ? config.target.trim() : null;
const githubClient = await createAuthenticatedGitHubClient(config);
const isStaged = isStagedMode(config);

Expand All @@ -51,7 +52,7 @@ async function main(config = {}) {
defaultName = `${defaultName} (Result)`;
}

core.info(`Create check run configuration: name="${defaultName}", max=${maxCount}`);
core.info(`Create check run configuration: name="${defaultName}", max=${maxCount}${checkRunTarget ? `, target=${checkRunTarget}` : ""}`);
if (configOutputTitle) core.info(`Config output.title fallback set (${configOutputTitle.length} chars)`);
if (configOutputSummary) core.info(`Config output.summary fallback set (${configOutputSummary.length} chars)`);

Expand Down Expand Up @@ -111,42 +112,101 @@ async function main(config = {}) {

const owner = context.repo.owner;
const repo = context.repo.repo;
let headSha = "";
let resolvedPrNumber = null;

// For pull_request events, GITHUB_SHA is the ephemeral merge commit SHA which is
// not visible in the PR checks UI or the GitHub mobile app. Use the actual PR head
// SHA from the event payload instead so the check run appears on the PR.
const prHeadSha = context.payload?.pull_request?.head?.sha;
const headSha = prHeadSha || process.env.GITHUB_SHA || context.sha;
if (checkRunTarget) {
Comment thread
pelikhan marked this conversation as resolved.
const targetResult = resolveTarget({
targetConfig: checkRunTarget,
item: message,
context,
itemType: HANDLER_TYPE,
supportsPR: false,
supportsIssue: false,
});
if (!targetResult.success) {
if (targetResult.shouldFail) {
core.error(targetResult.error);
} else {
core.info(targetResult.error);
}
return {
success: false,
error: targetResult.error,
skipped: !targetResult.shouldFail,
};
}

if (!headSha) {
const msg = "create_check_run: cannot determine commit SHA for check run";
core.error(msg);
return { success: false, error: msg };
}
resolvedPrNumber = targetResult.number;

if (prHeadSha) {
core.info(`Using PR head SHA ${prHeadSha} (pull_request event)`);
// Fetch the current PR head SHA via the API. We intentionally go through the API
// even when the context payload already carries a SHA (e.g. target: "triggering" on
// a pull_request event) so that we always use the most recent head in case the PR
// was force-pushed between the triggering event and when this handler runs.
// Skipped in staged mode — there is nothing to attach a real check run to.
if (!isStaged) {
try {
const { data: pullRequest } = await withRetry(
() =>
githubClient.rest.pulls.get({
owner,
repo,
pull_number: resolvedPrNumber,
}),
RATE_LIMIT_RETRY_CONFIG
);
headSha = pullRequest?.head?.sha || "";
if (!headSha) {
const msg = `create_check_run: pull request #${resolvedPrNumber} has no head SHA`;
core.error(msg);
return { success: false, error: msg };
}
core.info(`Using PR #${resolvedPrNumber} head SHA ${headSha} (target=${checkRunTarget})`);
} catch (error) {
const errorMessage = getErrorMessage(error);
const msg = `Failed to resolve pull request for create_check_run: ${errorMessage}`;
core.error(msg);
return { success: false, error: msg };
}
}
} else {
// For pull_request events, GITHUB_SHA is the ephemeral merge commit SHA which is
// not visible in the PR checks UI or the GitHub mobile app. Use the actual PR head
// SHA from the event payload instead so the check run appears on the PR.
const prHeadSha = context.payload?.pull_request?.head?.sha;
headSha = prHeadSha || process.env.GITHUB_SHA || context.sha;
if (prHeadSha) {
core.info(`Using PR head SHA ${prHeadSha} (pull_request event)`);
}
}

const checkRunName = defaultName;

core.info(`Creating check run "${checkRunName}" on ${owner}/${repo}@${headSha} with conclusion=${conclusion}`);

// If in staged mode, preview without executing
// In staged mode, preview without making live API calls to create the actual check run.
// Include the resolved PR number in the preview when targeting a specific PR.
if (isStaged) {
logStagedPreviewInfo(`Would create check run "${checkRunName}" with conclusion=${conclusion}, title="${resolvedTitle}"`);
const prSuffix = resolvedPrNumber != null ? ` targeting PR #${resolvedPrNumber}` : "";
logStagedPreviewInfo(`Would create check run "${defaultName}"${prSuffix} with conclusion=${conclusion}, title="${resolvedTitle}"`);
processedCount++;
return {
success: true,
staged: true,
previewInfo: {
name: checkRunName,
name: defaultName,
conclusion,
title: resolvedTitle,
},
};
}

if (!headSha) {
const msg = "create_check_run: cannot determine commit SHA for check run";
core.error(msg);
return { success: false, error: msg };
}

const checkRunName = defaultName;

core.info(`Creating check run "${checkRunName}" on ${owner}/${repo}@${headSha} with conclusion=${conclusion}`);

try {
const output = {
title: resolvedTitle,
Expand Down
120 changes: 120 additions & 0 deletions actions/setup/js/create_check_run.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,126 @@ describe("create_check_run", () => {
process.env.GITHUB_SHA = "sha-abc123";
});

describe("target resolution", () => {
beforeEach(() => {
process.env.GITHUB_SHA = "sha-abc123";
mockContext.eventName = "workflow_dispatch";
mockContext.payload = {};
});

it("uses pull_request_number when target is '*'", async () => {
mockGithub.rest.pulls = {
get: async ({ pull_number }) => ({
data: { number: pull_number, head: { sha: "target-pr-sha-42" } },
}),
};

let capturedParams;
mockGithub.rest.checks.create = makeChecksCreate(p => {
capturedParams = p;
});

const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "*" });
const result = await handler({ type: "create_check_run", pull_request_number: 42, conclusion: "success", title: "Title", summary: "Summary" }, {});

expect(result.success).toBe(true);
expect(capturedParams.head_sha).toBe("target-pr-sha-42");
});

it("returns error when target is '*' and pull_request_number is missing", async () => {
const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "*" });
const result = await handler({ type: "create_check_run", conclusion: "success", title: "Title", summary: "Summary" }, {});

expect(result.success).toBe(false);
expect(result.error).toContain('Target is "*"');
});

it("uses explicit target PR number from config", async () => {
mockGithub.rest.pulls = {
get: async ({ pull_number }) => ({
data: { number: pull_number, head: { sha: "target-pr-sha-7" } },
}),
};

let capturedParams;
mockGithub.rest.checks.create = makeChecksCreate(p => {
capturedParams = p;
});

const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "7" });
const result = await handler({ type: "create_check_run", conclusion: "success", title: "Title", summary: "Summary" }, {});

expect(result.success).toBe(true);
expect(capturedParams.head_sha).toBe("target-pr-sha-7");
});

it("returns error and emits core.error when pulls.get throws (e.g. 404 or 403)", async () => {
mockGithub.rest.pulls = {
get: async () => {
throw new Error("Not Found");
},
};

let coreErrorMessage = null;
mockCore.error = msg => {
coreErrorMessage = msg;
};

const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "*" });
const result = await handler({ type: "create_check_run", pull_request_number: 42, conclusion: "success", title: "Title", summary: "Summary" }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("Failed to resolve pull request");
expect(coreErrorMessage).toContain("Failed to resolve pull request");
});

it("resolves triggering PR context when target is 'triggering'", async () => {
mockContext.eventName = "pull_request";
mockContext.payload = { pull_request: { number: 99, head: { sha: "payload-sha-99" } } };
mockGithub.rest.pulls = {
get: async ({ pull_number }) => ({
data: { number: pull_number, head: { sha: "api-sha-99" } },
}),
};

let capturedParams;
mockGithub.rest.checks.create = makeChecksCreate(p => {
capturedParams = p;
});

const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "triggering" });
const result = await handler({ type: "create_check_run", conclusion: "success", title: "T", summary: "S" }, {});

expect(result.success).toBe(true);
// Uses the API-fetched SHA (not the payload SHA) so it is always current
expect(capturedParams.head_sha).toBe("api-sha-99");
});

it("skips pulls.get API call and returns staged preview when target is set and staged mode is active", async () => {
process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true";
let pullsGetCalled = false;
mockGithub.rest.pulls = {
get: async () => {
pullsGetCalled = true;
return { data: { head: { sha: "should-not-be-called" } } };
},
};

const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10, target: "*" });
const result = await handler({ type: "create_check_run", pull_request_number: 42, conclusion: "failure", title: "Title", summary: "Summary" }, {});

expect(pullsGetCalled).toBe(false);
expect(result.success).toBe(true);
expect(result.staged).toBe(true);
});
});
Comment thread
pelikhan marked this conversation as resolved.

it("returns error when conclusion is missing", async () => {
const { main } = require("./create_check_run.cjs");
const handler = await main({ max: 10 });
Expand Down
24 changes: 22 additions & 2 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@
"temporary_id": {
"type": "string",
"pattern": "^#?aw_[A-Za-z0-9_]{3,12}$",
"description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.",
"description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.",
"x-synonyms": ["temporaryId"]
},
"secrecy": {
Expand Down Expand Up @@ -1621,7 +1621,7 @@
},
{
"name": "create_check_run",
"description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name.",
"description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.",
"inputSchema": {
"type": "object",
"required": ["conclusion", "title", "summary"],
Expand All @@ -1642,6 +1642,26 @@
"text": {
"type": "string",
"description": "Optional detailed Markdown content shown in the check run details. Use this for longer output such as full analysis reports, line-by-line findings, or remediation steps. Maximum 65535 characters."
},
"pull_request_number": {
"type": ["number", "string"],
"description": "Pull request number to attach the check run to when the workflow uses `create-check-run: target: \"*\"` (or equivalent explicit PR targeting). This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876).",
"x-synonyms": ["pr_number", "pr", "pull_number"]
},
"pr_number": {
Comment thread
pelikhan marked this conversation as resolved.
"type": ["number", "string"],
"description": "Alias for pull_request_number. Prefer pull_request_number in new calls.",
"x-synonyms": ["prNumber"]
},
"pr": {
"type": ["number", "string"],
"description": "Alias for pull_request_number. Prefer pull_request_number in new calls.",
"x-synonyms": ["pullRequest", "pull"]
},
"pull_number": {
"type": ["number", "string"],
"description": "Alias for pull_request_number. Prefer pull_request_number in new calls.",
"x-synonyms": ["pullNumber"]
}
},
"additionalProperties": false
Expand Down
Loading
Loading