diff --git a/actions/setup/js/evaluate_outcomes.cjs b/actions/setup/js/evaluate_outcomes.cjs index d60bd1008f3..f4dbaf149fc 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 fix", "wontfix", "duplicate", "invalid", "declined", "rejected", "closing as", "closed as", "closing this"]; // --------------------------------------------------------------------------- // Helpers @@ -190,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") { @@ -217,12 +219,15 @@ function normalizeOutcome(result, detail) { * 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 = { @@ -244,6 +249,13 @@ function evaluateItem(item, defaultRepo) { zero_touch: false, }; + if (type === "create_pull_request") { + return evaluateCreatePullRequestOutcome(item, itemRepo, out, ghAPIFn); + } + if (type === "push_to_pull_request_branch") { + return evaluatePushToPullRequestBranchOutcome(item, itemRepo, out, ghAPIFn); + } + if (!url) { out.detail = "no url"; setPendingAge(out, timestamp); @@ -254,7 +266,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 = ghAPIFn(`repos/${itemRepo}/issues/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -284,7 +296,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 = ghAPIFn(`repos/${itemRepo}/pulls/${num}`); if (!data || !data.state) { out.detail = "api error"; setPendingAge(out, timestamp); @@ -343,6 +355,361 @@ 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, ghAPIFn = ghAPI) { + 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 = ghAPIFn(`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; + } + + if (data.state === "closed") { + 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) { + out.resolution_sec = secondsBetween(data.created_at, data.closed_at); + } + return out; + } + + 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)) { + 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, ghAPIFn = ghAPI) { + 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 = ghAPIFn(`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 => 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); + + 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; + } + + // 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 && allPushedCommitsMissingFromHistory && beforeHead) { + out.result = "rejected"; + out.detail = "pushed commits were force-pushed away or branch reset"; + return out; + } + + 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 => { + const reviewCommit = normalizeCommitSHA(r?.commit_id); + return reviewCommit ? pushedShas.some(sha => shaMatches(sha, reviewCommit)) : false; + }); + + if (!hasReviewOnPushedCommit) { + 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; + } + + 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.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; + } + 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 : ""; +} + +/** + * 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} + */ +function shaMatches(a, b) { + const left = normalizeCommitSHA(a); + const right = normalizeCommitSHA(b); + if (!left || !right) return false; + if (left === right) return true; + const leftIsShorterOrEqual = left.length <= right.length; + const shorter = leftIsShorterOrEqual ? left : right; + const longer = leftIsShorterOrEqual ? right : left; + return shorter.length >= 7 && longer.startsWith(shorter); +} + +/** + * @param {object} item + * @returns {string[]} + */ +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); + 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 + * @param {(endpoint: string) => object | null} ghAPIFn + * @returns {boolean} + */ +function hasClosingSignal(repo, number, prData, ghAPIFn) { + const labels = Array.isArray(prData?.labels) ? prData.labels : []; + const hasClosingLabel = labels.some(label => { + const name = String(label?.name || "").toLowerCase(); + return CLOSING_LABEL_KEYWORDS.some(keyword => name.includes(keyword)); + }); + if (hasClosingLabel) return true; + + const commentsRaw = ghAPIFn(`repos/${repo}/issues/${number}/comments`); + if (!Array.isArray(commentsRaw)) return false; + return commentsRaw.some(comment => { + const body = String(comment?.body || "").toLowerCase(); + return CLOSING_COMMENT_KEYWORDS.some(keyword => body.includes(keyword)); + }); +} + +/** + * @param {string} repo + * @param {string} commitSHA + * @param {string} branchHeadSHA + * @param {(endpoint: string) => object | null} ghAPIFn + * @returns {boolean | null} + */ +function isCommitInBranchHistory(repo, commitSHA, branchHeadSHA, ghAPIFn) { + if (!commitSHA || !branchHeadSHA) return null; + 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 => 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; +} + +/** + * @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 @@ -614,4 +981,13 @@ if (require.main === module) { main(); } -module.exports = { main, evaluateItem, normalizeOutcome, readJSONL, secondsBetween, isoToEpoch }; +module.exports = { + main, + evaluateItem, + evaluateCreatePullRequestOutcome, + evaluatePushToPullRequestBranchOutcome, + normalizeOutcome, + readJSONL, + secondsBetween, + isoToEpoch, +}; diff --git a/actions/setup/js/evaluate_outcomes.test.cjs b/actions/setup/js/evaluate_outcomes.test.cjs index 024e3490d95..5f1f222be31 100644 --- a/actions/setup/js/evaluate_outcomes.test.cjs +++ b/actions/setup/js/evaluate_outcomes.test.cjs @@ -1,8 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createRequire } from "module"; const req = createRequire(import.meta.url); -const { normalizeOutcome } = req("./evaluate_outcomes.cjs"); +const { evaluateItem, normalizeOutcome } = req("./evaluate_outcomes.cjs"); + +/** + * @param {Record} apiResponses + */ +function mockAPI(apiResponses) { + return endpoint => { + if (!(endpoint in apiResponses)) { + return null; + } + return apiResponses[endpoint]; + }; +} describe("evaluate_outcomes.cjs", () => { it("maps existence-only fallback to weak unknown evidence", () => { @@ -21,3 +33,421 @@ describe("evaluate_outcomes.cjs", () => { }); }); }); + +describe("evaluate_outcomes create_pull_request 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 as accepted (strong)", () => { + const ghAPI = 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", { 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)", () => { + 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", { ghAPI }); + expect(result.result).toBe("accepted"); + expect(result.detail).toContain("approved"); + }); + + it("classifies closed-with-label PR as rejected (strong)", () => { + 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", { ghAPI }); + expect(result.result).toBe("rejected"); + expect(result.detail).toContain("strong"); + }); + + it("classifies closed-without-merge PR as rejected", () => { + 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", { ghAPI }); + expect(result.result).toBe("rejected"); + expect(result.detail).toBe("closed without merge"); + }); + + it("classifies open PR with no reviews as pending", () => { + 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", { 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"; + 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", { ghAPI }); + expect(result.result).toBe("ignored"); + expect(result.detail).toContain("stale"); + }); + + 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"); + }); +}); + +describe("evaluate_outcomes push_to_pull_request_branch evaluator", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-27T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.GH_AW_OUTCOME_STALE_AFTER_SECONDS; + }); + + it("classifies merged PR with pushed commit included as accepted (strong)", () => { + const ghAPI = 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", + { 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 open PR with pushed commit still at HEAD as accepted (medium)", () => { + const ghAPI = 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", + { ghAPI } + ); + expect(result.result).toBe("accepted"); + 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" } }, + "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", + { ghAPI } + ); + expect(result.result).toBe("rejected"); + 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-26T23:50: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" } }, + "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", + { 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", () => { + 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": [], + }); + + const result = evaluateItem( + { + type: "push_to_pull_request_branch", + repo: "owner/repo", + pull_request_number: 25, + commit_sha: "aaaaaaa", + timestamp: "2026-05-26T23:50:00Z", + }, + "owner/repo", + { ghAPI } + ); + expect(result.result).toBe("pending"); + 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" } }, + "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", + { ghAPI } + ); + 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", () => { + 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"); + }); +}); 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