From 79f652315e9a46e244d9218471cef671c36bfa2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:32:37 +0000 Subject: [PATCH 01/11] Initial plan From 8ff514146c6b933ee64854ffd8bd447bcf116efd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:46:17 +0000 Subject: [PATCH 02/11] Add dedicated PR and push outcome evaluators Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 351 +++++++++++++++++++- actions/setup/js/evaluate_outcomes.test.cjs | 250 ++++++++++++++ 2 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 actions/setup/js/evaluate_outcomes.test.cjs diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index 3fd30d87947..ecc739e7825 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -76,6 +76,25 @@ function ghAPI(endpoint) { } } +/** @type {(endpoint: string) => object | null} */ +let ghAPIImpl = ghAPI; + +/** + * @param {string} endpoint + * @returns {object | null} + */ +function callGHAPI(endpoint) { + return ghAPIImpl(endpoint); +} + +/** + * Test-only override for GitHub API calls. + * @param {((endpoint: string) => object | null) | null} fn + */ +function __setGHAPIForTest(fn) { + ghAPIImpl = typeof fn === "function" ? fn : ghAPI; +} + /** * Read a JSON file, returning a default value on failure. * @param {string} filePath @@ -180,6 +199,7 @@ function evaluateItem(item, defaultRepo) { const url = item.url || ""; const itemRepo = item.repo || defaultRepo; const timestamp = item.timestamp || ""; + const type = item.type || ""; /** @type {EvalResult} */ const out = { @@ -198,6 +218,13 @@ function evaluateItem(item, defaultRepo) { zero_touch: false, }; + if (type === "create_pull_request") { + return evaluateCreatePullRequestOutcome(item, itemRepo, out); + } + if (type === "push_to_pull_request_branch") { + return evaluatePushToPullRequestBranchOutcome(item, itemRepo, out); + } + if (!url) { out.detail = "no url"; setPendingAge(out, timestamp); @@ -208,7 +235,7 @@ function evaluateItem(item, defaultRepo) { const issueMatch = url.match(/\/(?:issues|pull)\/(\d+)/); if (/\/issues\/\d+|\/issuecomment-/.test(url) && issueMatch) { const num = issueMatch[1]; - const data = ghAPI(`repos/${itemRepo}/issues/${num}`); + const data = callGHAPI(`repos/${itemRepo}/issues/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -238,7 +265,7 @@ function evaluateItem(item, defaultRepo) { const prMatch = url.match(/\/pull\/(\d+)/); if (prMatch) { const num = prMatch[1]; - const data = ghAPI(`repos/${itemRepo}/pulls/${num}`); + const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -296,6 +323,324 @@ function evaluateItem(item, defaultRepo) { return out; } +/** + * Evaluate outcome for create_pull_request. + * @param {object} item + * @param {string} itemRepo + * @param {EvalResult} out + * @returns {EvalResult} + */ +function evaluateCreatePullRequestOutcome(item, itemRepo, out) { + const num = resolvePRNumber(item); + const timestamp = item.timestamp || ""; + + if (!num || !itemRepo) { + out.result = "unknown"; + out.detail = "missing pull request reference"; + setPendingAge(out, timestamp); + return out; + } + + const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); + if (!data || !data.state) { + out.result = "unknown"; + out.detail = "api error"; + setPendingAge(out, timestamp); + return out; + } + + out.review_comments = typeof data.review_comments === "number" ? data.review_comments : null; + out.changed_files = typeof data.changed_files === "number" ? data.changed_files : null; + out.additions = typeof data.additions === "number" ? data.additions : null; + out.deletions = typeof data.deletions === "number" ? data.deletions : null; + out.comments = typeof data.comments === "number" ? data.comments : null; + + if (data.merged === true) { + out.result = "accepted"; + out.detail = "merged (strong)"; + if (data.created_at && data.merged_at) { + out.resolution_sec = secondsBetween(data.created_at, data.merged_at); + } + if (out.review_comments === 0 && out.comments === 0) { + out.zero_touch = true; + } + return out; + } + + const reviewsRaw = callGHAPI(`repos/${itemRepo}/pulls/${num}/reviews`); + const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; + const hasApproved = reviews.some(r => (r?.state || "").toUpperCase() === "APPROVED"); + const hasChangesRequested = reviews.some(r => (r?.state || "").toUpperCase() === "CHANGES_REQUESTED"); + + if (data.state === "closed") { + const closingSignal = hasClosingSignal(itemRepo, num, data); + out.result = "rejected"; + out.detail = closingSignal ? "closed without merge (strong)" : "closed without merge"; + if (data.created_at && data.closed_at) { + out.resolution_sec = secondsBetween(data.created_at, data.closed_at); + } + return out; + } + + if (data.state === "open") { + if (hasApproved && !hasChangesRequested) { + out.result = "accepted"; + out.detail = "approved without requested changes"; + return out; + } + if (reviews.length === 0) { + setPendingAge(out, timestamp); + if (isStalePending(out.pending_age_sec)) { + out.result = "ignored"; + out.detail = "open and stale"; + } else { + out.result = "pending"; + out.detail = "open with no reviews"; + } + return out; + } + out.result = "unknown"; + out.detail = "open with mixed review state"; + setPendingAge(out, timestamp); + return out; + } + + out.result = "unknown"; + out.detail = "unknown pull request state"; + setPendingAge(out, timestamp); + return out; +} + +/** + * Evaluate outcome for push_to_pull_request_branch. + * @param {object} item + * @param {string} itemRepo + * @param {EvalResult} out + * @returns {EvalResult} + */ +function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out) { + const num = resolvePRNumber(item); + const timestamp = item.timestamp || ""; + const pushedShas = extractPushedCommitSHAs(item); + const beforeHead = extractBeforeHeadSHA(item); + + if (!num || !itemRepo) { + out.result = "unknown"; + out.detail = "missing pull request reference"; + setPendingAge(out, timestamp); + return out; + } + + const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); + if (!data || !data.state) { + out.result = "unknown"; + out.detail = "api error"; + setPendingAge(out, timestamp); + return out; + } + + const currentHead = normalizeCommitSHA(data?.head?.sha); + + const pushedStillHead = currentHead ? pushedShas.some(sha => sha === currentHead) : false; + const pushedIncluded = + currentHead && pushedShas.length > 0 + ? pushedShas.some(sha => { + const inHistory = isCommitInBranchHistory(itemRepo, sha, currentHead); + return inHistory === true; + }) + : false; + + if (data.merged === true) { + out.result = "accepted"; + out.detail = pushedIncluded ? "merged with pushed commit retained (strong)" : "merged"; + if (data.created_at && data.merged_at) { + out.resolution_sec = secondsBetween(data.created_at, data.merged_at); + } + return out; + } + + if (data.state === "closed") { + out.result = "rejected"; + out.detail = "closed without merge"; + if (data.created_at && data.closed_at) { + out.resolution_sec = secondsBetween(data.created_at, data.closed_at); + } + return out; + } + + if (data.state !== "open") { + out.result = "unknown"; + out.detail = "unknown pull request state"; + setPendingAge(out, timestamp); + return out; + } + + if (pushedStillHead) { + out.result = "accepted"; + out.detail = "pushed commit is current branch head"; + return out; + } + + if (pushedShas.length > 0 && !pushedIncluded && beforeHead) { + out.result = "rejected"; + out.detail = "pushed commits were force-pushed away or branch reset"; + return out; + } + + const reviewsRaw = callGHAPI(`repos/${itemRepo}/pulls/${num}/reviews`); + const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; + const hasReviewOnPushedCommit = pushedShas.length > 0 && reviews.some(r => pushedShas.includes(normalizeCommitSHA(r?.commit_id))); + + if (!hasReviewOnPushedCommit) { + out.result = "pending"; + out.detail = "open with no review on pushed commits"; + setPendingAge(out, timestamp); + return out; + } + + out.result = "unknown"; + out.detail = "open with reviewed pushed commits"; + setPendingAge(out, timestamp); + return out; +} + +/** + * @param {object} item + * @returns {number} + */ +function resolvePRNumber(item) { + if (typeof item.number === "number" && item.number > 0) return item.number; + const candidates = [item.pull_request_number, item.pr_number, item.item_number]; + for (const candidate of candidates) { + const n = Number.parseInt(String(candidate || ""), 10); + if (Number.isInteger(n) && n > 0) return n; + } + const url = item.url || ""; + const prMatch = url.match(/\/pull\/(\d+)/); + if (!prMatch) return 0; + const n = Number.parseInt(prMatch[1], 10); + return Number.isInteger(n) && n > 0 ? n : 0; +} + +/** + * @param {string | null | undefined} sha + * @returns {string} + */ +function normalizeCommitSHA(sha) { + if (!sha || typeof sha !== "string") return ""; + const normalized = sha.trim().toLowerCase(); + return /^[0-9a-f]{7,40}$/.test(normalized) ? normalized : ""; +} + +/** + * @param {object} item + * @returns {string[]} + */ +function extractPushedCommitSHAs(item) { + /** @type {string[]} */ + const shas = []; + const candidates = [ + item.commit_sha, + item.pushed_commit_sha, + item.head_sha, + item?.metadata?.commit_sha, + item?.metadata?.pushed_commit_sha, + ]; + for (const candidate of candidates) { + const normalized = normalizeCommitSHA(candidate); + if (normalized) shas.push(normalized); + } + const listCandidates = [item.commit_shas, item.pushed_commit_shas, item?.metadata?.commit_shas, item?.metadata?.pushed_commit_shas]; + for (const list of listCandidates) { + if (!Array.isArray(list)) continue; + for (const value of list) { + const normalized = normalizeCommitSHA(value); + if (normalized) shas.push(normalized); + } + } + return [...new Set(shas)]; +} + +/** + * @param {object} item + * @returns {string} + */ +function extractBeforeHeadSHA(item) { + const candidates = [ + item.before_head_sha, + item.previous_head_sha, + item.head_sha_before, + item.branch_head_before, + item.pre_push_head_sha, + item?.metadata?.before_head_sha, + item?.metadata?.previous_head_sha, + item?.metadata?.head_sha_before, + ]; + for (const candidate of candidates) { + const normalized = normalizeCommitSHA(candidate); + if (normalized) return normalized; + } + return ""; +} + +/** + * @param {string} repo + * @param {number} number + * @param {any} prData + * @returns {boolean} + */ +function hasClosingSignal(repo, number, prData) { + const labels = Array.isArray(prData?.labels) ? prData.labels : []; + const labelKeywords = ["not planned", "not_planned", "wontfix", "won't fix", "duplicate", "invalid", "declined", "rejected"]; + const hasClosingLabel = labels.some(label => { + const name = String(label?.name || "").toLowerCase(); + return labelKeywords.some(keyword => name.includes(keyword)); + }); + if (hasClosingLabel) return true; + + const commentsRaw = callGHAPI(`repos/${repo}/issues/${number}/comments`); + if (!Array.isArray(commentsRaw)) return false; + const commentKeywords = ["not planned", "won't", "wontfix", "duplicate", "invalid", "declin", "reject", "closing"]; + return commentsRaw.some(comment => { + const body = String(comment?.body || "").toLowerCase(); + return commentKeywords.some(keyword => body.includes(keyword)); + }); +} + +/** + * @param {string} repo + * @param {string} commitSHA + * @param {string} branchHeadSHA + * @returns {boolean | null} + */ +function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA) { + if (!commitSHA || !branchHeadSHA) return null; + if (commitSHA === branchHeadSHA) return true; + const compareData = callGHAPI(`repos/${repo}/compare/${commitSHA}...${branchHeadSHA}`); + if (!compareData || typeof compareData.status !== "string") return null; + const status = compareData.status.toLowerCase(); + if (status === "ahead" || status === "identical") return true; + if (status === "behind" || status === "diverged") return false; + return null; +} + +/** + * @returns {number} + */ +function staleThresholdSec() { + const raw = Number.parseInt(String(process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS || ""), 10); + if (Number.isInteger(raw) && raw > 0) return raw; + return 7 * 24 * 60 * 60; +} + +/** + * @param {number | null} pendingAgeSec + * @returns {boolean} + */ +function isStalePending(pendingAgeSec) { + return typeof pendingAgeSec === "number" && pendingAgeSec >= staleThresholdSec(); +} + /** * Set pending_age_sec on the result if the item has a timestamp. * @param {EvalResult} out @@ -534,4 +879,4 @@ if (require.main === module) { main(); } -module.exports = { main, evaluateItem, readJSONL, secondsBetween, isoToEpoch }; +module.exports = { main, evaluateItem, evaluateCreatePullRequestOutcome, evaluatePushToPullRequestBranchOutcome, readJSONL, secondsBetween, isoToEpoch, __setGHAPIForTest }; diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs new file mode 100644 index 00000000000..4305a2ddcc2 --- /dev/null +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { evaluateItem, __setGHAPIForTest } = req("./evaluate_outcomes.cjs"); + +/** + * @param {Record} apiResponses + */ +function mockAPI(apiResponses) { + __setGHAPIForTest(endpoint => { + if (!(endpoint in apiResponses)) { + return null; + } + return apiResponses[endpoint]; + }); +} + +describe("evaluate_outcomes create_pull_request evaluator", () => { + beforeEach(() => { + __setGHAPIForTest(null); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); + delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; + }); + + it("classifies merged PR as accepted (strong)", () => { + mockAPI({ + "repos/owner/repo/pulls/12": { + state: "closed", + merged: true, + created_at: "2026-05-20T00:00:00Z", + merged_at: "2026-05-21T00:00:00Z", + review_comments: 0, + comments: 0, + }, + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/12", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("strong"); + }); + + it("classifies approved open PR as accepted (medium)", () => { + mockAPI({ + "repos/owner/repo/pulls/13": { state: "open", merged: false }, + "repos/owner/repo/pulls/13/reviews": [{ state: "APPROVED" }], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/13", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("approved"); + }); + + it("classifies closed-with-label PR as rejected (strong)", () => { + mockAPI({ + "repos/owner/repo/pulls/14": { state: "closed", merged: false, labels: [{ name: "not planned" }] }, + "repos/owner/repo/pulls/14/reviews": [], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/14", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("rejected"); + expect(result.detail).toContain("strong"); + }); + + it("classifies closed-without-merge PR as rejected", () => { + mockAPI({ + "repos/owner/repo/pulls/15": { state: "closed", merged: false, labels: [] }, + "repos/owner/repo/pulls/15/reviews": [], + "repos/owner/repo/issues/15/comments": [], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/15", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("rejected"); + expect(result.detail).toBe("closed without merge"); + }); + + it("classifies open PR with no reviews as pending", () => { + mockAPI({ + "repos/owner/repo/pulls/16": { state: "open", merged: false }, + "repos/owner/repo/pulls/16/reviews": [], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/16", timestamp: "2026-05-26T23:50:00Z" }, "owner/repo"); + expect(result.result).toBe("pending"); + expect(result.detail).toContain("no reviews"); + }); + + it("classifies stale open PR with no reviews as ignored", () => { + process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS = "60"; + mockAPI({ + "repos/owner/repo/pulls/17": { state: "open", merged: false }, + "repos/owner/repo/pulls/17/reviews": [], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/17", timestamp: "2026-05-26T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("ignored"); + expect(result.detail).toContain("stale"); + }); + + it("classifies open PR with mixed review state as unknown", () => { + mockAPI({ + "repos/owner/repo/pulls/18": { state: "open", merged: false }, + "repos/owner/repo/pulls/18/reviews": [{ state: "CHANGES_REQUESTED" }], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/18", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + expect(result.result).toBe("unknown"); + }); +}); + +describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { + beforeEach(() => { + __setGHAPIForTest(null); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("classifies merged PR with pushed commit included as accepted (strong)", () => { + mockAPI({ + "repos/owner/repo/pulls/21": { state: "closed", merged: true, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 21, + commit_sha: "aaaaaaa", + before_head_sha: "ccccccc", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("strong"); + }); + + it("classifies open PR with pushed commit still at HEAD as accepted (medium)", () => { + mockAPI({ + "repos/owner/repo/pulls/22": { state: "open", merged: false, head: { sha: "aaaaaaa" } }, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 22, + commit_sha: "aaaaaaa", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("head"); + }); + + it("classifies force-pushed-away commits as rejected (strong)", () => { + mockAPI({ + "repos/owner/repo/pulls/23": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "behind" }, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 23, + commit_sha: "aaaaaaa", + before_head_sha: "ccccccc", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("rejected"); + expect(result.detail).toContain("force-pushed"); + }); + + it("classifies closed-without-merge PR as rejected (medium)", () => { + mockAPI({ + "repos/owner/repo/pulls/24": { state: "closed", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 24, + commit_sha: "aaaaaaa", + before_head_sha: "ccccccc", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("rejected"); + expect(result.detail).toContain("closed without merge"); + }); + + it("classifies open PR with no reviews on pushed commits as pending", () => { + mockAPI({ + "repos/owner/repo/pulls/25": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + "repos/owner/repo/pulls/25/reviews": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 25, + commit_sha: "aaaaaaa", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("pending"); + expect(result.detail).toContain("no review"); + }); + + it("falls back to unknown when pushed commits are reviewed but not head/merged", () => { + mockAPI({ + "repos/owner/repo/pulls/26": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + "repos/owner/repo/pulls/26/reviews": [{ state: "APPROVED", commit_id: "aaaaaaa" }], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 26, + commit_sha: "aaaaaaa", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo" + ); + expect(result.result).toBe("unknown"); + }); +}); From 9aa84f881d9a587b956e9d1643b675033e68bd5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:54:41 +0000 Subject: [PATCH 03/11] Format evaluator helper arrays Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index ecc739e7825..da1dcd26c63 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -539,13 +539,7 @@ function normalizeCommitSHA(sha) { function extractPushedCommitSHAs(item) { /** @type {string[]} */ const shas = []; - const candidates = [ - item.commit_sha, - item.pushed_commit_sha, - item.head_sha, - item?.metadata?.commit_sha, - item?.metadata?.pushed_commit_sha, - ]; + const candidates = [item.commit_sha, item.pushed_commit_sha, item.head_sha, item?.metadata?.commit_sha, item?.metadata?.pushed_commit_sha]; for (const candidate of candidates) { const normalized = normalizeCommitSHA(candidate); if (normalized) shas.push(normalized); @@ -566,16 +560,7 @@ function extractPushedCommitSHAs(item) { * @returns {string} */ function extractBeforeHeadSHA(item) { - const candidates = [ - item.before_head_sha, - item.previous_head_sha, - item.head_sha_before, - item.branch_head_before, - item.pre_push_head_sha, - item?.metadata?.before_head_sha, - item?.metadata?.previous_head_sha, - item?.metadata?.head_sha_before, - ]; + const candidates = [item.before_head_sha, item.previous_head_sha, item.head_sha_before, item.branch_head_before, item.pre_push_head_sha, item?.metadata?.before_head_sha, item?.metadata?.previous_head_sha, item?.metadata?.head_sha_before]; for (const candidate of candidates) { const normalized = normalizeCommitSHA(candidate); if (normalized) return normalized; From c253b0a5e7ef28b4c464b1093a8205c4f80b3108 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:59:02 +0000 Subject: [PATCH 04/11] Inject API client into outcome evaluator tests Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 70 +++++++++------------ actions/setup/js/evaluate_outcomes.test.cjs | 66 ++++++++++--------- 2 files changed, 64 insertions(+), 72 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index da1dcd26c63..44a07898563 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -41,6 +41,8 @@ const SUMMARY_PATH = "/tmp/gh-aw/outcome-summary.json"; // Noop types that are tracked but not counted as actionable // --------------------------------------------------------------------------- const NOOP_TYPES = new Set(["noop", "missing_tool", "missing_data", "report_incomplete"]); +const CLOSING_LABEL_KEYWORDS = ["not planned", "not_planned", "wontfix", "won't fix", "duplicate", "invalid", "declined", "rejected"]; +const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't", "wontfix", "duplicate", "invalid", "declin", "reject", "closing"]; // --------------------------------------------------------------------------- // Helpers @@ -76,25 +78,6 @@ function ghAPI(endpoint) { } } -/** @type {(endpoint: string) => object | null} */ -let ghAPIImpl = ghAPI; - -/** - * @param {string} endpoint - * @returns {object | null} - */ -function callGHAPI(endpoint) { - return ghAPIImpl(endpoint); -} - -/** - * Test-only override for GitHub API calls. - * @param {((endpoint: string) => object | null) | null} fn - */ -function __setGHAPIForTest(fn) { - ghAPIImpl = typeof fn === "function" ? fn : ghAPI; -} - /** * Read a JSON file, returning a default value on failure. * @param {string} filePath @@ -193,13 +176,15 @@ function secondsBetween(from, to) { * Evaluate a single safe-output item against the GitHub API. * @param {object} item * @param {string} defaultRepo + * @param {{ ghAPI?: (endpoint: string) => object | null }} [options] * @returns {EvalResult} */ -function evaluateItem(item, defaultRepo) { +function evaluateItem(item, defaultRepo, options = {}) { const url = item.url || ""; const itemRepo = item.repo || defaultRepo; const timestamp = item.timestamp || ""; const type = item.type || ""; + const ghAPIFn = typeof options.ghAPI === "function" ? options.ghAPI : ghAPI; /** @type {EvalResult} */ const out = { @@ -219,10 +204,10 @@ function evaluateItem(item, defaultRepo) { }; if (type === "create_pull_request") { - return evaluateCreatePullRequestOutcome(item, itemRepo, out); + return evaluateCreatePullRequestOutcome(item, itemRepo, out, ghAPIFn); } if (type === "push_to_pull_request_branch") { - return evaluatePushToPullRequestBranchOutcome(item, itemRepo, out); + return evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn); } if (!url) { @@ -235,7 +220,7 @@ function evaluateItem(item, defaultRepo) { const issueMatch = url.match(/\/(?:issues|pull)\/(\d+)/); if (/\/issues\/\d+|\/issuecomment-/.test(url) && issueMatch) { const num = issueMatch[1]; - const data = callGHAPI(`repos/${itemRepo}/issues/${num}`); + const data = ghAPIFn(`repos/${itemRepo}/issues/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -265,7 +250,7 @@ function evaluateItem(item, defaultRepo) { const prMatch = url.match(/\/pull\/(\d+)/); if (prMatch) { const num = prMatch[1]; - const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); + const data = ghAPIFn(`repos/${itemRepo}/pulls/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -330,7 +315,7 @@ function evaluateItem(item, defaultRepo) { * @param {EvalResult} out * @returns {EvalResult} */ -function evaluateCreatePullRequestOutcome(item, itemRepo, out) { +function evaluateCreatePullRequestOutcome(item, itemRepo, out, ghAPIFn = ghAPI) { const num = resolvePRNumber(item); const timestamp = item.timestamp || ""; @@ -341,7 +326,7 @@ function evaluateCreatePullRequestOutcome(item, itemRepo, out) { return out; } - const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); + const data = ghAPIFn(`repos/${itemRepo}/pulls/${num}`); if (!data || !data.state) { out.result = "unknown"; out.detail = "api error"; @@ -367,13 +352,13 @@ function evaluateCreatePullRequestOutcome(item, itemRepo, out) { return out; } - const reviewsRaw = callGHAPI(`repos/${itemRepo}/pulls/${num}/reviews`); + const reviewsRaw = ghAPIFn(`repos/${itemRepo}/pulls/${num}/reviews`); const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; const hasApproved = reviews.some(r => (r?.state || "").toUpperCase() === "APPROVED"); const hasChangesRequested = reviews.some(r => (r?.state || "").toUpperCase() === "CHANGES_REQUESTED"); if (data.state === "closed") { - const closingSignal = hasClosingSignal(itemRepo, num, data); + const closingSignal = hasClosingSignal(itemRepo, num, data, ghAPIFn); out.result = "rejected"; out.detail = closingSignal ? "closed without merge (strong)" : "closed without merge"; if (data.created_at && data.closed_at) { @@ -418,7 +403,7 @@ function evaluateCreatePullRequestOutcome(item, itemRepo, out) { * @param {EvalResult} out * @returns {EvalResult} */ -function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out) { +function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = ghAPI) { const num = resolvePRNumber(item); const timestamp = item.timestamp || ""; const pushedShas = extractPushedCommitSHAs(item); @@ -431,7 +416,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out) { return out; } - const data = callGHAPI(`repos/${itemRepo}/pulls/${num}`); + const data = ghAPIFn(`repos/${itemRepo}/pulls/${num}`); if (!data || !data.state) { out.result = "unknown"; out.detail = "api error"; @@ -445,7 +430,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out) { const pushedIncluded = currentHead && pushedShas.length > 0 ? pushedShas.some(sha => { - const inHistory = isCommitInBranchHistory(itemRepo, sha, currentHead); + const inHistory = isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn); return inHistory === true; }) : false; @@ -481,13 +466,15 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out) { return out; } + // A strong rejection requires before-head metadata from execution time so we + // can distinguish "commit not retained" from "insufficient history context". if (pushedShas.length > 0 && !pushedIncluded && beforeHead) { out.result = "rejected"; out.detail = "pushed commits were force-pushed away or branch reset"; return out; } - const reviewsRaw = callGHAPI(`repos/${itemRepo}/pulls/${num}/reviews`); + const reviewsRaw = ghAPIFn(`repos/${itemRepo}/pulls/${num}/reviews`); const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; const hasReviewOnPushedCommit = pushedShas.length > 0 && reviews.some(r => pushedShas.includes(normalizeCommitSHA(r?.commit_id))); @@ -574,21 +561,19 @@ function extractBeforeHeadSHA(item) { * @param {any} prData * @returns {boolean} */ -function hasClosingSignal(repo, number, prData) { +function hasClosingSignal(repo, number, prData, ghAPIFn) { const labels = Array.isArray(prData?.labels) ? prData.labels : []; - const labelKeywords = ["not planned", "not_planned", "wontfix", "won't fix", "duplicate", "invalid", "declined", "rejected"]; const hasClosingLabel = labels.some(label => { const name = String(label?.name || "").toLowerCase(); - return labelKeywords.some(keyword => name.includes(keyword)); + return CLOSING_LABEL_KEYWORDS.some(keyword => name.includes(keyword)); }); if (hasClosingLabel) return true; - const commentsRaw = callGHAPI(`repos/${repo}/issues/${number}/comments`); + const commentsRaw = ghAPIFn(`repos/${repo}/issues/${number}/comments`); if (!Array.isArray(commentsRaw)) return false; - const commentKeywords = ["not planned", "won't", "wontfix", "duplicate", "invalid", "declin", "reject", "closing"]; return commentsRaw.some(comment => { const body = String(comment?.body || "").toLowerCase(); - return commentKeywords.some(keyword => body.includes(keyword)); + return CLOSING_COMMENT_KEYWORDS.some(keyword => body.includes(keyword)); }); } @@ -598,12 +583,15 @@ function hasClosingSignal(repo, number, prData) { * @param {string} branchHeadSHA * @returns {boolean | null} */ -function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA) { +function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA, ghAPIFn) { if (!commitSHA || !branchHeadSHA) return null; if (commitSHA === branchHeadSHA) return true; - const compareData = callGHAPI(`repos/${repo}/compare/${commitSHA}...${branchHeadSHA}`); + const compareData = ghAPIFn(`repos/${repo}/compare/${commitSHA}...${branchHeadSHA}`); if (!compareData || typeof compareData.status !== "string") return null; const status = compareData.status.toLowerCase(); + // compare base...head semantics: + // - ahead/identical => base commit is in head history + // - behind/diverged => base commit is not retained on the evaluated branch tip if (status === "ahead" || status === "identical") return true; if (status === "behind" || status === "diverged") return false; return null; @@ -864,4 +852,4 @@ if (require.main === module) { main(); } -module.exports = { main, evaluateItem, evaluateCreatePullRequestOutcome, evaluatePushToPullRequestBranchOutcome, readJSONL, secondsBetween, isoToEpoch, __setGHAPIForTest }; +module.exports = { main, evaluateItem, evaluateCreatePullRequestOutcome, evaluatePushToPullRequestBranchOutcome, readJSONL, secondsBetween, isoToEpoch }; diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs index 4305a2ddcc2..80334a913a4 100644 --- a/actions/setup/js/evaluate_outcomes.test.cjs +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -2,23 +2,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { evaluateItem, __setGHAPIForTest } = req("./evaluate_outcomes.cjs"); +const { evaluateItem } = req("./evaluate_outcomes.cjs"); /** * @param {Record} apiResponses */ function mockAPI(apiResponses) { - __setGHAPIForTest(endpoint => { + return endpoint => { if (!(endpoint in apiResponses)) { return null; } return apiResponses[endpoint]; - }); + }; } describe("evaluate_outcomes create_pull_request evaluator", () => { beforeEach(() => { - __setGHAPIForTest(null); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; @@ -30,7 +29,7 @@ describe("evaluate_outcomes create_pull_request evaluator", () => { }); it("classifies merged PR as accepted (strong)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/12": { state: "closed", merged: true, @@ -41,82 +40,81 @@ describe("evaluate_outcomes create_pull_request evaluator", () => { }, }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/12", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/12", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("accepted"); expect(result.detail).toContain("strong"); }); it("classifies approved open PR as accepted (medium)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/13": { state: "open", merged: false }, "repos/owner/repo/pulls/13/reviews": [{ state: "APPROVED" }], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/13", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/13", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("accepted"); expect(result.detail).toContain("approved"); }); it("classifies closed-with-label PR as rejected (strong)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/14": { state: "closed", merged: false, labels: [{ name: "not planned" }] }, "repos/owner/repo/pulls/14/reviews": [], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/14", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/14", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("rejected"); expect(result.detail).toContain("strong"); }); it("classifies closed-without-merge PR as rejected", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/15": { state: "closed", merged: false, labels: [] }, "repos/owner/repo/pulls/15/reviews": [], "repos/owner/repo/issues/15/comments": [], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/15", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/15", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("rejected"); expect(result.detail).toBe("closed without merge"); }); it("classifies open PR with no reviews as pending", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/16": { state: "open", merged: false }, "repos/owner/repo/pulls/16/reviews": [], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/16", timestamp: "2026-05-26T23:50:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/16", timestamp: "2026-05-26T23:50:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("pending"); expect(result.detail).toContain("no reviews"); }); it("classifies stale open PR with no reviews as ignored", () => { process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS = "60"; - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/17": { state: "open", merged: false }, "repos/owner/repo/pulls/17/reviews": [], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/17", timestamp: "2026-05-26T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/17", timestamp: "2026-05-26T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("ignored"); expect(result.detail).toContain("stale"); }); it("classifies open PR with mixed review state as unknown", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/18": { state: "open", merged: false }, "repos/owner/repo/pulls/18/reviews": [{ state: "CHANGES_REQUESTED" }], }); - const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/18", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo"); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/18", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("unknown"); }); }); describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { beforeEach(() => { - __setGHAPIForTest(null); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); }); @@ -126,7 +124,7 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { }); it("classifies merged PR with pushed commit included as accepted (strong)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/21": { state: "closed", merged: true, head: { sha: "bbbbbbb" } }, "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, }); @@ -140,14 +138,15 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { before_head_sha: "ccccccc", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("accepted"); expect(result.detail).toContain("strong"); }); it("classifies open PR with pushed commit still at HEAD as accepted (medium)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/22": { state: "open", merged: false, head: { sha: "aaaaaaa" } }, }); @@ -159,14 +158,15 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { commit_sha: "aaaaaaa", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("accepted"); expect(result.detail).toContain("head"); }); it("classifies force-pushed-away commits as rejected (strong)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/23": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "behind" }, }); @@ -180,14 +180,15 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { before_head_sha: "ccccccc", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("rejected"); expect(result.detail).toContain("force-pushed"); }); it("classifies closed-without-merge PR as rejected (medium)", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/24": { state: "closed", merged: false, head: { sha: "bbbbbbb" } }, "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, }); @@ -201,14 +202,15 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { before_head_sha: "ccccccc", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("rejected"); expect(result.detail).toContain("closed without merge"); }); it("classifies open PR with no reviews on pushed commits as pending", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/25": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, "repos/owner/repo/pulls/25/reviews": [], @@ -222,14 +224,15 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { commit_sha: "aaaaaaa", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("pending"); expect(result.detail).toContain("no review"); }); it("falls back to unknown when pushed commits are reviewed but not head/merged", () => { - mockAPI({ + const ghAPI = mockAPI({ "repos/owner/repo/pulls/26": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, "repos/owner/repo/pulls/26/reviews": [{ state: "APPROVED", commit_id: "aaaaaaa" }], @@ -243,7 +246,8 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { commit_sha: "aaaaaaa", timestamp: "2026-05-20T00:00:00Z", }, - "owner/repo" + "owner/repo", + { ghAPI } ); expect(result.result).toBe("unknown"); }); From 2a3a0c3e8d2c9ac9f30b415107adc9df68920d17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 02:59:44 +0000 Subject: [PATCH 05/11] Add missing evaluator JSDoc parameter types Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index 44a07898563..be898e4affe 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -559,6 +559,7 @@ function extractBeforeHeadSHA(item) { * @param {string} repo * @param {number} number * @param {any} prData + * @param {(endpoint: string) => object | null} ghAPIFn * @returns {boolean} */ function hasClosingSignal(repo, number, prData, ghAPIFn) { @@ -581,6 +582,7 @@ function hasClosingSignal(repo, number, prData, ghAPIFn) { * @param {string} repo * @param {string} commitSHA * @param {string} branchHeadSHA + * @param {(endpoint: string) => object | null} ghAPIFn * @returns {boolean | null} */ function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA, ghAPIFn) { From 3422c05f97161d03cf4090213af6f426240192aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:26:50 +0000 Subject: [PATCH 06/11] Fix evaluator review feedback and add edge-case tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 16 +++---- actions/setup/js/evaluate_outcomes.test.cjs | 53 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index 67e3e8f1cc5..62b85729124 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -192,7 +192,7 @@ function normalizeOutcome(result, detail) { if (normalizedDetail === "object still exists") { return { outcome_status: "unknown", evidence_strength: "weak", signal: "target_exists_only" }; } - if (result === "accepted" && normalizedDetail === "merged") { + if (result === "accepted" && normalizedDetail.startsWith("merged")) { return { outcome_status: "accepted", evidence_strength: "strong", signal: "merged" }; } if (result === "rejected" && normalizedDetail === "closed") { @@ -474,13 +474,9 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g const currentHead = normalizeCommitSHA(data?.head?.sha); const pushedStillHead = currentHead ? pushedShas.some(sha => sha === currentHead) : false; - const pushedIncluded = - currentHead && pushedShas.length > 0 - ? pushedShas.some(sha => { - const inHistory = isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn); - return inHistory === true; - }) - : false; + const commitRetentionChecks = currentHead && pushedShas.length > 0 ? pushedShas.map(sha => isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn)) : []; + const pushedIncluded = commitRetentionChecks.some(inHistory => inHistory === true); + const pushedDefinitivelyNotRetained = commitRetentionChecks.length > 0 && commitRetentionChecks.every(inHistory => inHistory === false); if (data.merged === true) { out.result = "accepted"; @@ -515,7 +511,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g // A strong rejection requires before-head metadata from execution time so we // can distinguish "commit not retained" from "insufficient history context". - if (pushedShas.length > 0 && !pushedIncluded && beforeHead) { + if (pushedShas.length > 0 && pushedDefinitivelyNotRetained && beforeHead) { out.result = "rejected"; out.detail = "pushed commits were force-pushed away or branch reset"; return out; @@ -544,7 +540,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g */ function resolvePRNumber(item) { if (typeof item.number === "number" && item.number > 0) return item.number; - const candidates = [item.pull_request_number, item.pr_number, item.item_number]; + const candidates = [item.pull_request_number, item.pr_number, item.pr, item.pull_number, item.item_number]; for (const candidate of candidates) { const n = Number.parseInt(String(candidate || ""), 10); if (Number.isInteger(n) && n > 0) return n; diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs index 07b4ae49a7e..c8fa8203896 100644 --- a/actions/setup/js/evaluate_outcomes.test.cjs +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -61,6 +61,11 @@ describe("evaluate_outcomes create_pull_request evaluator", () => { const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/12", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("accepted"); expect(result.detail).toContain("strong"); + expect(normalizeOutcome(result.result, result.detail)).toEqual({ + outcome_status: "accepted", + evidence_strength: "strong", + signal: "merged", + }); }); it("classifies approved open PR as accepted (medium)", () => { @@ -161,6 +166,11 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { ); expect(result.result).toBe("accepted"); expect(result.detail).toContain("strong"); + expect(normalizeOutcome(result.result, result.detail)).toEqual({ + outcome_status: "accepted", + evidence_strength: "strong", + signal: "merged", + }); }); it("classifies open PR with pushed commit still at HEAD as accepted (medium)", () => { @@ -205,6 +215,29 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { expect(result.detail).toContain("force-pushed"); }); + it("does not classify unknown retention as force-pushed-away", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/29": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": null, + "repos/owner/repo/pulls/29/reviews": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 29, + commit_sha: "aaaaaaa", + before_head_sha: "ccccccc", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("pending"); + expect(result.detail).toContain("no review"); + }); + it("classifies closed-without-merge PR as rejected (medium)", () => { const ghAPI = mockAPI({ "repos/owner/repo/pulls/24": { state: "closed", merged: false, head: { sha: "bbbbbbb" } }, @@ -270,3 +303,23 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { expect(result.result).toBe("unknown"); }); }); + +describe("evaluate_outcomes pull request number aliases", () => { + it("resolves PR number from pr alias", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/31": { state: "open", merged: false }, + "repos/owner/repo/pulls/31/reviews": [], + }); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", pr: 31 }, "owner/repo", { ghAPI }); + expect(result.result).toBe("pending"); + }); + + it("resolves PR number from pull_number alias", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/32": { state: "open", merged: false }, + "repos/owner/repo/pulls/32/reviews": [], + }); + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", pull_number: 32 }, "owner/repo", { ghAPI }); + expect(result.result).toBe("pending"); + }); +}); From fe07a0bcfe0f1b6223871a100e290fb6407d5ada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:27:42 +0000 Subject: [PATCH 07/11] Clarify commit-retention variable names Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index 62b85729124..9929e9c0b72 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -474,9 +474,9 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g const currentHead = normalizeCommitSHA(data?.head?.sha); const pushedStillHead = currentHead ? pushedShas.some(sha => sha === currentHead) : false; - const commitRetentionChecks = currentHead && pushedShas.length > 0 ? pushedShas.map(sha => isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn)) : []; - const pushedIncluded = commitRetentionChecks.some(inHistory => inHistory === true); - const pushedDefinitivelyNotRetained = commitRetentionChecks.length > 0 && commitRetentionChecks.every(inHistory => inHistory === false); + const commitRetentionResults = currentHead && pushedShas.length > 0 ? pushedShas.map(sha => isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn)) : []; + const pushedIncluded = commitRetentionResults.some(inHistory => inHistory === true); + const allPushedCommitsMissingFromHistory = commitRetentionResults.length > 0 && commitRetentionResults.every(inHistory => inHistory === false); if (data.merged === true) { out.result = "accepted"; @@ -511,7 +511,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g // A strong rejection requires before-head metadata from execution time so we // can distinguish "commit not retained" from "insufficient history context". - if (pushedShas.length > 0 && pushedDefinitivelyNotRetained && beforeHead) { + if (pushedShas.length > 0 && allPushedCommitsMissingFromHistory && beforeHead) { out.result = "rejected"; out.detail = "pushed commits were force-pushed away or branch reset"; return out; From f4c432ef9868195631c82a4165b517d76b19f1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:40:56 +0000 Subject: [PATCH 08/11] Address github-actions evaluator review feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 70 ++++++++-- actions/setup/js/evaluate_outcomes.test.cjs | 135 +++++++++++++++++++- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index 9929e9c0b72..e75a9249fa8 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -42,7 +42,7 @@ const SUMMARY_PATH = "/tmp/gh-aw/outcome-summary.json"; // --------------------------------------------------------------------------- const NOOP_TYPES = new Set(["noop", "missing_tool", "missing_data", "report_incomplete"]); const CLOSING_LABEL_KEYWORDS = ["not planned", "not_planned", "wontfix", "won't fix", "duplicate", "invalid", "declined", "rejected"]; -const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't", "wontfix", "duplicate", "invalid", "declin", "reject", "closing"]; +const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't fix", "wontfix", "duplicate", "invalid", "declin", "reject", "closing as", "closed as", "closing this"]; // --------------------------------------------------------------------------- // Helpers @@ -399,11 +399,6 @@ function evaluateCreatePullRequestOutcome(item, itemRepo, out, ghAPIFn = ghAPI) return out; } - const reviewsRaw = ghAPIFn(`repos/${itemRepo}/pulls/${num}/reviews`); - const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; - const hasApproved = reviews.some(r => (r?.state || "").toUpperCase() === "APPROVED"); - const hasChangesRequested = reviews.some(r => (r?.state || "").toUpperCase() === "CHANGES_REQUESTED"); - if (data.state === "closed") { const closingSignal = hasClosingSignal(itemRepo, num, data, ghAPIFn); out.result = "rejected"; @@ -415,11 +410,28 @@ function evaluateCreatePullRequestOutcome(item, itemRepo, out, ghAPIFn = ghAPI) } if (data.state === "open") { + const reviewsRaw = ghAPIFn(`repos/${itemRepo}/pulls/${num}/reviews`); + if (reviewsRaw === null) { + out.result = "unknown"; + out.detail = "reviews api error"; + setPendingAge(out, timestamp); + return out; + } + const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; + const hasApproved = reviews.some(r => (r?.state || "").toUpperCase() === "APPROVED"); + const hasChangesRequested = reviews.some(r => (r?.state || "").toUpperCase() === "CHANGES_REQUESTED"); + if (hasApproved && !hasChangesRequested) { out.result = "accepted"; out.detail = "approved without requested changes"; return out; } + if (hasChangesRequested && !hasApproved) { + out.result = "pending"; + out.detail = "open with changes requested"; + setPendingAge(out, timestamp); + return out; + } if (reviews.length === 0) { setPendingAge(out, timestamp); if (isStalePending(out.pending_age_sec)) { @@ -473,7 +485,7 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g const currentHead = normalizeCommitSHA(data?.head?.sha); - const pushedStillHead = currentHead ? pushedShas.some(sha => sha === currentHead) : false; + const pushedStillHead = currentHead ? pushedShas.some(sha => shaMatches(sha, currentHead)) : false; const commitRetentionResults = currentHead && pushedShas.length > 0 ? pushedShas.map(sha => isCommitInBranchHistory(itemRepo, sha, currentHead, ghAPIFn)) : []; const pushedIncluded = commitRetentionResults.some(inHistory => inHistory === true); const allPushedCommitsMissingFromHistory = commitRetentionResults.length > 0 && commitRetentionResults.every(inHistory => inHistory === false); @@ -518,13 +530,29 @@ function evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn = g } const reviewsRaw = ghAPIFn(`repos/${itemRepo}/pulls/${num}/reviews`); + if (reviewsRaw === null) { + out.result = "unknown"; + out.detail = "reviews api error"; + setPendingAge(out, timestamp); + return out; + } const reviews = Array.isArray(reviewsRaw) ? reviewsRaw : []; - const hasReviewOnPushedCommit = pushedShas.length > 0 && reviews.some(r => pushedShas.includes(normalizeCommitSHA(r?.commit_id))); + const hasReviewOnPushedCommit = + pushedShas.length > 0 && + reviews.some(r => { + const reviewCommit = normalizeCommitSHA(r?.commit_id); + return reviewCommit ? pushedShas.some(sha => shaMatches(sha, reviewCommit)) : false; + }); if (!hasReviewOnPushedCommit) { - out.result = "pending"; - out.detail = "open with no review on pushed commits"; setPendingAge(out, timestamp); + if (isStalePending(out.pending_age_sec)) { + out.result = "ignored"; + out.detail = "open and stale with no review on pushed commits"; + } else { + out.result = "pending"; + out.detail = "open with no review on pushed commits"; + } return out; } @@ -562,6 +590,21 @@ function normalizeCommitSHA(sha) { return /^[0-9a-f]{7,40}$/.test(normalized) ? normalized : ""; } +/** + * @param {string} a + * @param {string} b + * @returns {boolean} + */ +function shaMatches(a, b) { + const left = normalizeCommitSHA(a); + const right = normalizeCommitSHA(b); + if (!left || !right) return false; + if (left === right) return true; + const shorter = left.length <= right.length ? left : right; + const longer = left.length <= right.length ? right : left; + return shorter.length >= 7 && longer.startsWith(shorter); +} + /** * @param {object} item * @returns {string[]} @@ -569,7 +612,7 @@ function normalizeCommitSHA(sha) { function extractPushedCommitSHAs(item) { /** @type {string[]} */ const shas = []; - const candidates = [item.commit_sha, item.pushed_commit_sha, item.head_sha, item?.metadata?.commit_sha, item?.metadata?.pushed_commit_sha]; + const candidates = [item.commit_sha, item.pushed_commit_sha, item?.metadata?.commit_sha, item?.metadata?.pushed_commit_sha]; for (const candidate of candidates) { const normalized = normalizeCommitSHA(candidate); if (normalized) shas.push(normalized); @@ -630,13 +673,14 @@ function hasClosingSignal(repo, number, prData, ghAPIFn) { */ function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA, ghAPIFn) { if (!commitSHA || !branchHeadSHA) return null; - if (commitSHA === branchHeadSHA) return true; + if (shaMatches(commitSHA, branchHeadSHA)) return true; const compareData = ghAPIFn(`repos/${repo}/compare/${commitSHA}...${branchHeadSHA}`); if (!compareData || typeof compareData.status !== "string") return null; const status = compareData.status.toLowerCase(); // compare base...head semantics: // - ahead/identical => base commit is in head history - // - behind/diverged => base commit is not retained on the evaluated branch tip + // - behind => evaluated head is behind base, so base is not retained at this tip + // - diverged => evaluated head diverged from base, so base is not retained if (status === "ahead" || status === "identical") return true; if (status === "behind" || status === "diverged") return false; return null; diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs index c8fa8203896..4c7b869a0d2 100644 --- a/actions/setup/js/evaluate_outcomes.test.cjs +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -125,14 +125,37 @@ describe("evaluate_outcomes create_pull_request evaluator", () => { expect(result.detail).toContain("stale"); }); - it("classifies open PR with mixed review state as unknown", () => { + it("classifies open PR with only CHANGES_REQUESTED as pending", () => { const ghAPI = mockAPI({ "repos/owner/repo/pulls/18": { state: "open", merged: false }, "repos/owner/repo/pulls/18/reviews": [{ state: "CHANGES_REQUESTED" }], }); const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/18", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); + expect(result.result).toBe("pending"); + expect(result.detail).toContain("changes requested"); + }); + + it("classifies open PR with mixed APPROVED and CHANGES_REQUESTED as unknown", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/19": { state: "open", merged: false }, + "repos/owner/repo/pulls/19/reviews": [{ state: "APPROVED" }, { state: "CHANGES_REQUESTED" }], + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/19", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); + expect(result.result).toBe("unknown"); + expect(result.detail).toContain("mixed"); + }); + + it("classifies open PR as unknown when reviews API fails", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/20": { state: "open", merged: false }, + "repos/owner/repo/pulls/20/reviews": null, + }); + + const result = evaluateItem({ type: "create_pull_request", repo: "owner/repo", url: "https://github.com/owner/repo/pull/20", timestamp: "2026-05-20T00:00:00Z" }, "owner/repo", { ghAPI }); expect(result.result).toBe("unknown"); + expect(result.detail).toContain("reviews api error"); }); }); @@ -140,10 +163,12 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); + delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; }); afterEach(() => { vi.useRealTimers(); + delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; }); it("classifies merged PR with pushed commit included as accepted (strong)", () => { @@ -193,6 +218,26 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { expect(result.detail).toContain("head"); }); + it("matches short pushed SHA against full current HEAD SHA", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/30": { state: "open", merged: false, head: { sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 30, + commit_sha: "aaaaaaa", + timestamp: "2026-05-26T23:50:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("head"); + }); + it("classifies force-pushed-away commits as rejected (strong)", () => { const ghAPI = mockAPI({ "repos/owner/repo/pulls/23": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, @@ -229,7 +274,7 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { pull_request_number: 29, commit_sha: "aaaaaaa", before_head_sha: "ccccccc", - timestamp: "2026-05-20T00:00:00Z", + timestamp: "2026-05-26T23:50:00Z", }, "owner/repo", { ghAPI } @@ -273,7 +318,7 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { repo: "owner/repo", pull_request_number: 25, commit_sha: "aaaaaaa", - timestamp: "2026-05-20T00:00:00Z", + timestamp: "2026-05-26T23:50:00Z", }, "owner/repo", { ghAPI } @@ -282,6 +327,29 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { expect(result.detail).toContain("no review"); }); + it("classifies stale open PR with no reviews on pushed commits as ignored", () => { + process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS = "60"; + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/33": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + "repos/owner/repo/pulls/33/reviews": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 33, + commit_sha: "aaaaaaa", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("ignored"); + expect(result.detail).toContain("stale"); + }); + it("falls back to unknown when pushed commits are reviewed but not head/merged", () => { const ghAPI = mockAPI({ "repos/owner/repo/pulls/26": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, @@ -302,6 +370,67 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { ); expect(result.result).toBe("unknown"); }); + + it("classifies as unknown when push evaluator reviews API fails", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/34": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/compare/aaaaaaa...bbbbbbb": { status: "ahead" }, + "repos/owner/repo/pulls/34/reviews": null, + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 34, + commit_sha: "aaaaaaa", + timestamp: "2026-05-20T00:00:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("unknown"); + expect(result.detail).toContain("reviews api error"); + }); + + it("falls back to pending when push item has no commit SHA", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/27": { state: "open", merged: false, head: { sha: "bbbbbbb" } }, + "repos/owner/repo/pulls/27/reviews": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 27, + timestamp: "2026-05-26T23:50:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("pending"); + }); + + it("does not treat item.head_sha as pushed commit evidence", () => { + const ghAPI = mockAPI({ + "repos/owner/repo/pulls/35": { state: "open", merged: false, head: { sha: "aaaaaaa" } }, + "repos/owner/repo/pulls/35/reviews": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 35, + head_sha: "aaaaaaa", + timestamp: "2026-05-26T23:50:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("pending"); + }); }); describe("evaluate_outcomes pull request number aliases", () => { From 9db92315d6b64128c724c90c5733dc3836ee17b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:42:12 +0000 Subject: [PATCH 09/11] Tighten closing keywords and document SHA matching Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index e75a9249fa8..bd29288b379 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -42,7 +42,7 @@ const SUMMARY_PATH = "/tmp/gh-aw/outcome-summary.json"; // --------------------------------------------------------------------------- const NOOP_TYPES = new Set(["noop", "missing_tool", "missing_data", "report_incomplete"]); const CLOSING_LABEL_KEYWORDS = ["not planned", "not_planned", "wontfix", "won't fix", "duplicate", "invalid", "declined", "rejected"]; -const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't fix", "wontfix", "duplicate", "invalid", "declin", "reject", "closing as", "closed as", "closing this"]; +const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't fix", "wontfix", "duplicate", "invalid", "declined", "rejected", "closing as", "closed as", "closing this"]; // --------------------------------------------------------------------------- // Helpers @@ -591,6 +591,10 @@ function normalizeCommitSHA(sha) { } /** + * Match SHAs across short/full representations (7-40 hex chars). + * Returns true for exact matches and when the longer SHA starts with the + * shorter SHA prefix (minimum 7 chars). + * * @param {string} a * @param {string} b * @returns {boolean} From 62ee25fc4b3a95594afb1678bff2b9cc714485f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:43:40 +0000 Subject: [PATCH 10/11] Document ambiguous head SHA exclusion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/evaluate_outcomes.cjs | 7 +++++-- actions/setup/js/evaluate_outcomes.test.cjs | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index bd29288b379..f4dbaf149fc 100644 --- a/actions/setup/js/evaluate_outcomes.cjs +++ b/actions/setup/js/evaluate_outcomes.cjs @@ -604,8 +604,9 @@ function shaMatches(a, b) { const right = normalizeCommitSHA(b); if (!left || !right) return false; if (left === right) return true; - const shorter = left.length <= right.length ? left : right; - const longer = left.length <= right.length ? right : left; + const leftIsShorterOrEqual = left.length <= right.length; + const shorter = leftIsShorterOrEqual ? left : right; + const longer = leftIsShorterOrEqual ? right : left; return shorter.length >= 7 && longer.startsWith(shorter); } @@ -616,6 +617,8 @@ function shaMatches(a, b) { function extractPushedCommitSHAs(item) { /** @type {string[]} */ const shas = []; + // Intentionally exclude `item.head_sha`: it is ambiguous (tip-at-observation) + // and not a reliable indicator of what commit(s) were pushed in this action. const candidates = [item.commit_sha, item.pushed_commit_sha, item?.metadata?.commit_sha, item?.metadata?.pushed_commit_sha]; for (const candidate of candidates) { const normalized = normalizeCommitSHA(candidate); diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs index 4c7b869a0d2..5f1f222be31 100644 --- a/actions/setup/js/evaluate_outcomes.test.cjs +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -163,7 +163,6 @@ describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); - delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; }); afterEach(() => { From b3d4cf7abda23021dcba4ba21851eb3616153b17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 15:20:07 +0000 Subject: [PATCH 11/11] Merge main and stabilize timeout-sensitive push_repo_memory test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_repo_memory.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 970fdcbacdf..89cb694a4b7 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -1421,7 +1421,7 @@ describe("push_repo_memory.cjs - shell injection security tests", () => { } finally { nodeFs.rmSync(tmpArtifactDir, { recursive: true, force: true }); } - }); + }, 60_000); it("should discriminate between missing-branch and auth/network fetch failures (source check)", () => { // Verifies that the fetch-error handler inspects the error message and