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
7 changes: 4 additions & 3 deletions .github/workflows/agentic_commands.yml

Large diffs are not rendered by default.

20 changes: 17 additions & 3 deletions actions/setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,24 @@ function expectRestEndpoint(endpoint, endpointName, eventName) {
if (!isRestEndpoint(endpoint)) {
throw new Error(`${ERR_VALIDATION}: Unexpected ${endpointName} endpoint shape for event: ${eventName}`);
}

return endpoint;
}

/**
* @param {string} endpoint
* @param {"discussion"|"discussion_comment"} eventName
* @returns {number}
*/
function parseDiscussionEndpoint(endpoint, eventName) {
const match = endpoint.match(eventName === "discussion" ? /^discussion:([1-9]\d*)$/ : /^discussion_comment:([1-9]\d*):[1-9]\d*$/);
const discussionNumber = Number(match?.[1]);
if (!Number.isSafeInteger(discussionNumber)) {
throw new Error(`${ERR_VALIDATION}: Invalid discussion endpoint: ${endpoint}`);
}
return discussionNumber;
}

/**
* Resolve the reaction and comment API endpoints for a given event.
* Returns null (after calling core.setFailed) when the event or payload is invalid.
Expand Down Expand Up @@ -262,8 +277,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
if (typeof endpoint !== "string") {
throw new Error(`${ERR_VALIDATION}: Unexpected comment endpoint shape for event: ${eventName}`);
}
// Parse discussion number from special format: "discussion:NUMBER" or "discussion_comment:NUMBER:COMMENT_ID"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
const discussionNumber = parseDiscussionEndpoint(endpoint, eventName);
const discussionId = await getDiscussionNodeId(eventRepo.owner, eventRepo.repo, discussionNumber);
// For discussion_comment events, thread the reply under the triggering comment.
// GitHub Discussions only supports two nesting levels, so resolve the top-level parent node ID.
Expand Down Expand Up @@ -291,4 +305,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
}
}

module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction, expectRestEndpoint };
module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction, expectRestEndpoint, parseDiscussionEndpoint };
15 changes: 13 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, resolveEventEndpoints, VALID_REACTIONS, expectRestEndpoint } = await import("./add_reaction_and_edit_comment.cjs?" + Date.now());
return { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction, resolveEventEndpoints, VALID_REACTIONS, expectRestEndpoint };
const { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction, resolveEventEndpoints, VALID_REACTIONS, expectRestEndpoint, parseDiscussionEndpoint } = await import("./add_reaction_and_edit_comment.cjs?" + Date.now());
return { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction, resolveEventEndpoints, VALID_REACTIONS, expectRestEndpoint, parseDiscussionEndpoint };
}

describe("add_reaction_and_edit_comment.cjs", () => {
Expand Down Expand Up @@ -72,6 +72,17 @@ describe("add_reaction_and_edit_comment.cjs", () => {
});
});

describe("discussion endpoint validation", () => {
it("rejects malformed and non-positive discussion endpoint numbers", async () => {
const { parseDiscussionEndpoint } = await loadModule();

for (const endpoint of ["discussion:5junk", "discussion:0", "discussion:5:extra"]) {
expect(() => parseDiscussionEndpoint(endpoint, "discussion")).toThrow("Invalid discussion endpoint");
}
expect(() => parseDiscussionEndpoint("discussion_comment:5:2junk", "discussion_comment")).toThrow("Invalid discussion endpoint");
});
});

describe("Issue reactions", () => {
it("should add reaction to issue successfully", async () => {
process.env.GH_AW_REACTION = "eyes";
Expand Down
23 changes: 18 additions & 5 deletions actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function setCommentOutputs(commentId, commentUrl, eventRepo = context.repo, opti
} else {
core.info(`Successfully created comment with workflow link`);
}

core.info(`Comment ID: ${commentId}`);
core.info(`Comment URL: ${commentUrl}`);
core.info(`Comment Repo: ${eventRepo.owner}/${eventRepo.repo}`);
Expand All @@ -66,6 +67,20 @@ function setCommentOutputs(commentId, commentUrl, eventRepo = context.repo, opti
};
}

/**
* @param {string} endpoint
* @param {"discussion"|"discussion_comment"} eventName
* @returns {number}
*/
function parseDiscussionEndpoint(endpoint, eventName) {
const match = endpoint.match(eventName === "discussion" ? /^discussion:([1-9]\d*)$/ : /^discussion_comment:([1-9]\d*):[1-9]\d*$/);
const discussionNumber = Number(match?.[1]);
if (!Number.isSafeInteger(discussionNumber)) {
throw new Error(`${ERR_VALIDATION}: Invalid discussion endpoint: ${endpoint}`);
}
return discussionNumber;
}

/**
* @param {unknown} value
* @returns {Record<string, any>|null}
Expand Down Expand Up @@ -418,17 +433,15 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
if (typeof endpoint !== "string") {
throw new Error(`${ERR_VALIDATION}: Unexpected comment endpoint shape for event: ${eventName}`);
}
// Parse discussion number from special format: "discussion:NUMBER"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
const discussionNumber = parseDiscussionEndpoint(endpoint, eventName);
return postDiscussionComment(discussionNumber, commentBody, null, eventRepo);
}

if (eventName === "discussion_comment") {
if (typeof endpoint !== "string") {
throw new Error(`${ERR_VALIDATION}: Unexpected comment endpoint shape for event: ${eventName}`);
}
// Parse discussion number from special format: "discussion_comment:NUMBER:COMMENT_ID"
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
const discussionNumber = parseDiscussionEndpoint(endpoint, eventName);

// GitHub Discussions only supports two nesting levels, so resolve the top-level parent's node ID
const commentNodeId = await resolveTopLevelDiscussionCommentId(github, eventPayload?.comment?.node_id);
Expand All @@ -449,4 +462,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
return setCommentOutputs(createResponse.data.id, createResponse.data.html_url, eventRepo);
}

module.exports = { main, addCommentWithWorkflowLink, buildCommentBody, postDiscussionComment, createOrReuseStatusComment };
module.exports = { main, addCommentWithWorkflowLink, buildCommentBody, postDiscussionComment, createOrReuseStatusComment, parseDiscussionEndpoint };
11 changes: 11 additions & 0 deletions actions/setup/js/add_workflow_run_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ describe("add_workflow_run_comment", () => {
return import("./add_workflow_run_comment.cjs?test=" + importCounter);
}

describe("discussion endpoint validation", () => {
it("rejects malformed and non-positive discussion endpoint numbers", async () => {
const { parseDiscussionEndpoint } = await importAddWorkflowRunComment();

for (const endpoint of ["discussion:12junk", "discussion:0", "discussion:12:extra"]) {
expect(() => parseDiscussionEndpoint(endpoint, "discussion")).toThrow("Invalid discussion endpoint");
}
expect(() => parseDiscussionEndpoint("discussion_comment:5junk:2", "discussion_comment")).toThrow("Invalid discussion endpoint");
});
});

// Helper function to run the script
async function runScript() {
const { main } = await importAddWorkflowRunComment();
Expand Down
38 changes: 31 additions & 7 deletions actions/setup/js/artifact_client.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function readResponseText(response, context) {
try {
return await response.text();
} catch (error) {
throw new Error(`failed to read ${context} response body: ${getErrorMessage(error)}`, { cause: error });
}
}

async function readResponseJSON(response, context) {
try {
return await response.json();
} catch (error) {
throw new Error(`failed to parse ${context} response body: ${getErrorMessage(error)}`, { cause: error });
}
}

function makeTempDir(prefix) {
try {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
} catch (error) {
throw new Error(`failed to create temporary directory for ${prefix}: ${getErrorMessage(error)}`, { cause: error });
}
}

function parseURL(url, base, errorMessage) {
try {
return base === undefined ? new URL(url) : new URL(url, base);
Expand Down Expand Up @@ -108,10 +132,10 @@ async function twirpRequest(method, body) {
});

if (response.ok) {
return await response.json();
return await readResponseJSON(response, `artifact twirp ${method}`);
}

const responseBody = await response.text();
const responseBody = await readResponseText(response, `artifact twirp ${method}`);
const retryable = response.status >= 500 || response.status === 429;
if (!retryable || attempt === DEFAULT_RETRY_ATTEMPTS) {
throw new Error(`artifact twirp ${method} failed (${response.status}): ${responseBody || response.statusText}`);
Expand Down Expand Up @@ -238,7 +262,7 @@ async function uploadFileToSignedURL(filePath, signedUploadURL, contentType) {
throw new Error(`artifact blob upload failed: ${getErrorMessage(err)}`, { cause: err });
}
if (!response.ok) {
const body = await response.text();
const body = await readResponseText(response, "artifact blob upload");
throw new Error(`artifact blob upload failed (${response.status}): ${body || response.statusText}`);
}
return stats.size;
Expand Down Expand Up @@ -293,10 +317,10 @@ class DefaultArtifactClient {
throw new Error(`failed to list artifacts: ${getErrorMessage(err)}`, { cause: err });
}
if (!response.ok) {
throw new Error(`failed to list artifacts (${response.status}): ${await response.text()}`);
throw new Error(`failed to list artifacts (${response.status}): ${await readResponseText(response, "list artifacts")}`);
}
/** @type {any} */
const payload = await response.json();
const payload = await readResponseJSON(response, "list artifacts");
const pageArtifacts = Array.isArray(payload?.artifacts) ? payload.artifacts : [];
for (const item of pageArtifacts) {
artifacts.push({
Expand Down Expand Up @@ -372,7 +396,7 @@ class DefaultArtifactClient {
const zipLike = isZipResponse(location, contentType);
if (zipLike && !options.skipDecompress) {
ensureUnzipAvailable();
const tempDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-download-"));
const tempDownloadDir = makeTempDir("gh-aw-artifact-download-");
const tempZip = path.join(tempDownloadDir, "artifact.zip");
try {
digest = await streamToFile(blobResponse, tempZip);
Expand Down Expand Up @@ -420,7 +444,7 @@ class DefaultArtifactClient {
uploadPath = files[0];
contentType = "application/octet-stream";
} else {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-upload-"));
tmpDir = makeTempDir("gh-aw-artifact-upload-");
uploadPath = path.join(tmpDir, `${artifactName || "artifact"}.zip`);
createZipFromFiles(files, rootDirectory, uploadPath);
}
Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,12 @@ function endpointBaseUrl(endpoint) {
* @returns {string}
*/
function deriveBaseUrlFromModelsURL(modelsUrl, env = process.env, readFileSync = fs.readFileSync) {
const parsed = new URL(modelsUrl);
let parsed;
try {
parsed = new URL(modelsUrl);
} catch (error) {
throw new Error(`Invalid models URL: ${modelsUrl}`, { cause: error });
}
const basePath = parsed.pathname.replace(/\/models\/?$/i, "");
return rewriteAPIProxyURLForHostBridge(`${parsed.origin}${basePath}`, env, readFileSync);
}
Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,12 @@ async function getRunAIC(artifactClient, runId, token, owner, repo) {
artifactId: artifact.id,
artifactName: artifact.name,
});
const downloadRoot = fs.mkdtempSync(path.join(os.tmpdir(), `gh-aw-daily-guardrail-${runId}-`));
let downloadRoot;
try {
downloadRoot = fs.mkdtempSync(path.join(os.tmpdir(), `gh-aw-daily-guardrail-${runId}-`));
} catch (error) {
throw new Error(`Failed to create temporary artifact directory for run ${runId}: ${getErrorMessage(error)}`, { cause: error });
}
const download = await artifactClient.downloadArtifact(artifact.id, {
path: downloadRoot,
findBy: {
Expand Down
17 changes: 12 additions & 5 deletions actions/setup/js/check_rate_limit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ async function main() {

// Get configuration from environment variables
// Use .trim() + || so that empty/whitespace-only values also fall back to defaults
const maxRuns = parseInt(process.env.GH_AW_RATE_LIMIT_MAX?.trim() || "5", 10);
const windowMinutes = parseInt(process.env.GH_AW_RATE_LIMIT_WINDOW?.trim() || "60", 10);
const maxRuns = Number(process.env.GH_AW_RATE_LIMIT_MAX?.trim() || "5");
const windowMinutes = Number(process.env.GH_AW_RATE_LIMIT_WINDOW?.trim() || "60");
if (!Number.isFinite(maxRuns) || !Number.isSafeInteger(maxRuns) || maxRuns <= 0 || !Number.isFinite(windowMinutes) || !Number.isSafeInteger(windowMinutes) || windowMinutes <= 0) {
throw new Error("Rate limit maximum and window must be positive integers");
}
const eventsList = process.env.GH_AW_RATE_LIMIT_EVENTS?.trim() || "";
// Default: admin, maintain, and write roles are exempt from rate limiting
const ignoredRolesList = process.env.GH_AW_RATE_LIMIT_IGNORED_ROLES?.trim() || "admin,maintain,write";
Expand Down Expand Up @@ -109,8 +112,8 @@ async function main() {

// Calculate time threshold
const windowMs = windowMinutes * 60 * 1000;
const thresholdTime = new Date(Date.now() - windowMs);
const thresholdISO = thresholdTime.toISOString();
const thresholdTimestamp = Date.now() - windowMs;
const thresholdISO = new Date(thresholdTimestamp).toISOString();

core.info(` Time window: runs created after ${thresholdISO}`);

Expand Down Expand Up @@ -158,7 +161,11 @@ async function main() {

// Stop if run is older than the time window (runs are newest-first)
const runCreatedAt = new Date(run.created_at);
if (runCreatedAt < thresholdTime) {
if (Number.isNaN(runCreatedAt.getTime())) {
core.warning(`Skipping run ${run.id} with invalid creation date`);
continue;
}
if (runCreatedAt.getTime() < thresholdTimestamp) {
core.info(` Stopping pagination - run ${run.id} created before threshold (${run.created_at})`);
hasMore = false;
break;
Expand Down
15 changes: 14 additions & 1 deletion actions/setup/js/check_runs_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,20 @@ function selectLatestRelevantChecks(checkRuns, options = {}) {
continue;
}
const existing = latestByName.get(run.name);
if (!existing || new Date(run.started_at ?? 0) > new Date(existing.started_at ?? 0)) {
if (!existing) {
latestByName.set(run.name, run);
continue;
}
const runStartedAt = Date.parse(run.started_at ?? "");
const existingStartedAt = Date.parse(existing.started_at ?? "");
if (!Number.isFinite(runStartedAt)) {
continue;
}

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.

Bug: valid-timestamp run is silently dropped when existing entry has an invalid date

When existingStartedAt is NaN (the stored entry has a malformed started_at) but runStartedAt is a valid timestamp, the current guard:

if (!Number.isFinite(existingStartedAt)) {
  continue; // ← keeps the invalid entry, discards the valid one
}

silently discards the valid incoming run and retains the entry with the unusable date. The correct behaviour is to prefer the valid-date run:

if (!Number.isFinite(runStartedAt)) {
  continue; // incoming date is bad — keep existing regardless
}
if (!Number.isFinite(existingStartedAt)) {
  latestByName.set(run.name, run); // upgrade: incoming is valid, existing is not
  continue;
}
if (runStartedAt > existingStartedAt) {
  latestByName.set(run.name, run);
}

@copilot please address this.

if (!Number.isFinite(existingStartedAt)) {

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.

Replacing the old comparison with an unconditional continue when existing.started_at is invalid changes behavior in a bad way: once a malformed older run is already stored, every newer valid run with the same name is ignored forever, so this helper can report a stale check instead of the actual latest result.

💡 Why this matters and how to fix it

The previous code still allowed a valid newer run to replace an entry whose timestamp parsed badly because new Date(invalid) > new Date(valid) simply evaluated false for the bad candidate, not for the already-selected one. The new branch at line 52 turns that into a hard stop.

A safer fallback is to treat an invalid existing.started_at as older than any valid candidate:

if (!Number.isFinite(runStartedAt)) {
  continue;
}
if (!Number.isFinite(existingStartedAt) || runStartedAt > existingStartedAt) {
  latestByName.set(run.name, run);
}

That preserves the goal of skipping malformed incoming runs without pinning the map to a bad existing entry.

latestByName.set(run.name, run);
continue;
}
if (runStartedAt > existingStartedAt) {

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.

L49-55: shrink: two separate if (!Number.isFinite(...)) blocks. if (!Number.isFinite(runStartedAt) || !Number.isFinite(existingStartedAt)) continue;, then if (runStartedAt > existingStartedAt) ..., 2 lines saved.

latestByName.set(run.name, run);
}
}
Expand Down
8 changes: 8 additions & 0 deletions actions/setup/js/check_runs_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ describe("check_runs_helpers", () => {
expect(ci?.id).toBe(2);
});

it("replaces an invalid timestamp with a valid later same-name run", () => {
const { relevant } = selectLatestRelevantChecks([
{ id: 1, name: "CI", started_at: null, app: { slug: "github-actions" } },
{ id: 2, name: "CI", started_at: "2024-01-02T00:00:00Z", app: { slug: "github-actions" } },
]);
expect(relevant.find(r => r.name === "CI")?.id).toBe(2);
});

it("excludes deployment checks and reports count", () => {
const { relevant, deploymentCheckCount } = selectLatestRelevantChecks(runs);
expect(relevant.every(r => r.app?.slug !== "github-deployments")).toBe(true);
Expand Down
10 changes: 7 additions & 3 deletions actions/setup/js/create_prompt.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function parseConfig(value) {
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error(`${ERR_PARSE}: Invalid GH_AW_PROMPT_CONFIG: ${getErrorMessage(error)}`);
throw new Error(`${ERR_PARSE}: Invalid GH_AW_PROMPT_CONFIG: ${getErrorMessage(error)}`, { cause: error });
}
if (!parsed || !Array.isArray(parsed.items)) {
throw new Error(`${ERR_CONFIG}: GH_AW_PROMPT_CONFIG must contain an items array`);
Expand Down Expand Up @@ -83,7 +83,11 @@ function writePromptFile(promptPath, content) {
const fd = fs.openSync(promptPath, flags, 0o600);
try {
fs.fchmodSync(fd, 0o600);
fs.writeFileSync(fd, content, "utf8");
try {
fs.writeFileSync(fd, content, "utf8");
} catch (error) {
throw new Error(`${ERR_SYSTEM}: Failed to write prompt file ${promptPath}: ${getErrorMessage(error)}`, { cause: error });
}
} finally {
fs.closeSync(fd);
}
Expand Down Expand Up @@ -131,7 +135,7 @@ function renderPrompt(config, env, promptsDir) {
try {
result += fs.readFileSync(promptFile, "utf8");
} catch (error) {
throw new Error(`${ERR_SYSTEM}: Failed to read prompt file: ${getErrorMessage(error)}`);
throw new Error(`${ERR_SYSTEM}: Failed to read prompt file: ${getErrorMessage(error)}`, { cause: error });
}
}

Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/data_schema_normalizer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ function resolveDataSchema(rawSchema, path) {
return normalized;
}
if (typeof rawSchema === "string") {
const parsed = JSON.parse(rawSchema);
let parsed;
try {
parsed = JSON.parse(rawSchema);
} catch (error) {
throw new Error(`${path}: invalid JSON schema`, { cause: error });
}
if (!isPlainObject(parsed)) {
throw new Error(`${path}: string JSON must decode to an object schema`);
}
Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/evaluate_outcomes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,12 @@ function isOnOrAfter(timestamp, threshold) {
if (!threshold) return true;
const a = Date.parse(timestamp);
const b = Date.parse(threshold);
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
if (!Number.isFinite(a)) {
return false;
}
if (!Number.isFinite(b)) {
return false;
}
return a >= b;

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.

L1225-1231: shrink: split into two ifs from a single combined check. if (!Number.isFinite(a) || !Number.isFinite(b)) return false;, 3 lines saved.

}

Expand Down
Loading
Loading