From 98f7618af2e2dff6358b64668ec530f8e6af33cc Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Wed, 7 Oct 2026 01:36:52 +0000 Subject: [PATCH] Publish curated projectbrain source from 246c0a7f35e32f54fa1bf8a3f720b01bb00dd9af --- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 38 +-- backend/src/integrations/gitea/client.ts | 121 ++++++++- backend/src/integrations/gitea/policy.ts | 133 ++++++++-- backend/src/integrations/gitea/types.ts | 2 + backend/src/mcp/server.ts | 4 +- backend/src/routes/giteaIntegration.ts | 4 +- backend/test/database-autonomy.test.ts | 25 +- backend/test/gitea-integration.test.ts | 5 +- backend/test/native-ci-discovery.test.ts | 253 ++++++++++++++++++- backend/test/remediation-regressions.test.ts | 16 ++ 11 files changed, 540 insertions(+), 63 deletions(-) diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index bd4abcc..adf4a5a 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `1e4b1f8d0f6933393c04d27837f0c8d1313c04a5`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `246c0a7f35e32f54fa1bf8a3f720b01bb00dd9af`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 0251dc0..1ace705 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "1e4b1f8d0f6933393c04d27837f0c8d1313c04a5", + "sourceRevision": "246c0a7f35e32f54fa1bf8a3f720b01bb00dd9af", "files": [ { "path": ".dockerignore", @@ -309,8 +309,8 @@ }, { "path": "backend/src/integrations/gitea/client.ts", - "sha256": "1f88276e1174e47d262b58445c374915fbe835a27e1b1254f0bfe77f987767ca", - "bytes": 16592 + "sha256": "9ff455172f445e04f7bbac04b2fdaebbbc79eab58f92d9431a868e210875c0ca", + "bytes": 23963 }, { "path": "backend/src/integrations/gitea/compatibility.ts", @@ -319,8 +319,8 @@ }, { "path": "backend/src/integrations/gitea/policy.ts", - "sha256": "677d93e603f78c6a80cde5a7e2bc4c5529f507a9fa3780f1825bb29d3c4b584d", - "bytes": 6240 + "sha256": "e7326db011edc22e437131e6d64a517a6627e642809a23fb28fee7d4300109fe", + "bytes": 11897 }, { "path": "backend/src/integrations/gitea/portfolio.ts", @@ -334,8 +334,8 @@ }, { "path": "backend/src/integrations/gitea/types.ts", - "sha256": "96d115230cc462d1c0bf353ab92530708c0430f98a14b38440c70287a520c45b", - "bytes": 3525 + "sha256": "bb5f55930da1507b832949ce3de0bfaec41e765f67fa4973b873adb97550335a", + "bytes": 3576 }, { "path": "backend/src/mcp/analysisWidget.ts", @@ -369,8 +369,8 @@ }, { "path": "backend/src/mcp/server.ts", - "sha256": "c50d62ce8c32e73b820d4cd0a6b4ea64118a758cf8e8e6bdd3da43f37c670ce6", - "bytes": 78439 + "sha256": "98bd7f5df04996f1a44d51bd8110309eba548bc29e0272fe84420ceafe345255", + "bytes": 78477 }, { "path": "backend/src/mcp/skills.ts", @@ -594,8 +594,8 @@ }, { "path": "backend/src/routes/giteaIntegration.ts", - "sha256": "69dc078288aa0aa860ab4f09a97a93a28e6786047d602723f393c6b27410d8bf", - "bytes": 46130 + "sha256": "b474ba26897e392ae6bb43d79d59237ae60b9be2ceb9f0a04b0b27b22580c75b", + "bytes": 46165 }, { "path": "backend/src/routes/health.ts", @@ -984,8 +984,8 @@ }, { "path": "backend/test/database-autonomy.test.ts", - "sha256": "eca93db598282b142003bb9ffabb1a578b93175da36004a909ee112bbf1a4f52", - "bytes": 50622 + "sha256": "f05c5a72a4acf7dcef0ccb1b4ddf6334c559b06375959b0d4dbb2e916d9914be", + "bytes": 51663 }, { "path": "backend/test/database-repository-sync.test.ts", @@ -1034,8 +1034,8 @@ }, { "path": "backend/test/gitea-integration.test.ts", - "sha256": "2b7fb94dd40bd50c9e0aa2147540e690d8275ca37f2d948aaeb6ed9128b24b0e", - "bytes": 37780 + "sha256": "3c5cc8dd5d575996f89cda4e9db1d5b31f92121ed9e78945b1799740a8f9bcfd", + "bytes": 38396 }, { "path": "backend/test/gitea-portfolio-mcp.test.ts", @@ -1054,8 +1054,8 @@ }, { "path": "backend/test/native-ci-discovery.test.ts", - "sha256": "7b86c426bb31b47f0ea7207896e18bdaaee396a830437c13ed33564945f07746", - "bytes": 4926 + "sha256": "65637cdd3cd6b73935fffa7f3f28e34705b9ac83d1a921a6f4d79e395befc8b0", + "bytes": 16744 }, { "path": "backend/test/operatingSystemRoutes.test.ts", @@ -1099,8 +1099,8 @@ }, { "path": "backend/test/remediation-regressions.test.ts", - "sha256": "c13fcafb7ad72699e7d4aa2a64b555ea4bce6c88fb2ccd425d5154751af14d80", - "bytes": 5380 + "sha256": "4a5a2e7a0d98b3fdcf65940b1cfdd34e82034544010c4d2a681047a9cea8152d", + "bytes": 5848 }, { "path": "backend/test/repository-analysis.test.ts", diff --git a/backend/src/integrations/gitea/client.ts b/backend/src/integrations/gitea/client.ts index c9529e5..962797d 100644 --- a/backend/src/integrations/gitea/client.ts +++ b/backend/src/integrations/gitea/client.ts @@ -1,4 +1,4 @@ -import type { CreateGiteaRepositoryInput, GiteaActionJobList, GiteaActionRunnerList, GiteaActionRunList, GiteaBranch, GiteaContent, GiteaIssue, GiteaIssueComment, GiteaLabel, GiteaPullRequest, GiteaRepository, GiteaSearchResult, GiteaWebhook } from "./types.js"; +import type { CreateGiteaRepositoryInput, GiteaActionJobList, GiteaActionRunnerList, GiteaActionRun, GiteaActionRunList, GiteaBranch, GiteaContent, GiteaIssue, GiteaIssueComment, GiteaLabel, GiteaPullRequest, GiteaRepository, GiteaSearchResult, GiteaWebhook } from "./types.js"; export class GiteaApiError extends Error { constructor(public readonly status: number, public readonly code: string, message: string) { @@ -11,6 +11,17 @@ type GiteaBranchProtection = { enable_status_check?: boolean; status_check_conte type GiteaCombinedStatus = { state?: string; statuses?: Array<{ context?: string; status?: string }> }; import { GITEA_TESTED_MERGE_RANGE, giteaMergeCompatibility } from "./compatibility.js"; +import { actionRunMatchesPullRequestNumber, isManagedValidationWorkflowPath, managedValidationWorkflowPullRequestNumber } from "./policy.js"; + +const ACTION_JOBS_PAGE_SIZE = 100; +const ACTION_JOBS_MAX_PAGES = 10; +const ACTION_RUNS_PAGE_SIZE = 100; +const ACTION_RUNS_MAX_PAGES = 10; + +const runIsNewer = (candidate: GiteaActionRun, current: GiteaActionRun): boolean => + candidate.run_number > current.run_number + || (candidate.run_number === current.run_number && (candidate.run_attempt ?? 0) > (current.run_attempt ?? 0)) + || (candidate.run_number === current.run_number && (candidate.run_attempt ?? 0) === (current.run_attempt ?? 0) && candidate.id > current.id); export class GiteaClient { private readonly apiBase: string; @@ -233,12 +244,114 @@ export class GiteaClient { return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/labels`, { method: "PUT", body: { labels } }); } - listActionRuns(owner: string, repository: string, input: { branch?: string; headSha?: string; limit?: number } = {}) { - return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs`, { + async listActionRuns(owner: string, repository: string, input: { branch?: string; headSha?: string; pullRequestNumber?: number; limit?: number } = {}) { + const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs`; + const query = (page?: number) => ({ // Filtering by branch upstream hides native PR runs with head_branch="". // Exact-head callers perform branch/PR reconciliation in policy.ts. - query: { branch: input.headSha ? undefined : input.branch, head_sha: input.headSha, limit: input.limit ?? 30 }, + branch: input.headSha ? undefined : input.branch, + head_sha: input.headSha, + limit: input.headSha ? ACTION_RUNS_PAGE_SIZE : input.limit ?? 30, + page: input.headSha ? page ?? 1 : undefined, }); + let response = await this.request(path, { query: query(1) }); + if (!Array.isArray(response.workflow_runs)) throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea returned an invalid Actions run list"); + if (input.headSha) { + if (!Number.isSafeInteger(response.total_count) || response.total_count < 0 || response.workflow_runs.length > ACTION_RUNS_PAGE_SIZE) { + throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea returned an invalid exact-head Actions run page"); + } + const workflowRuns = [...response.workflow_runs]; + const expectedCount = response.total_count; + const seenRuns = new Set(); + const addPage = (runs: GiteaActionRun[]) => { + for (const run of runs) { + if (!run || typeof run !== "object" || !Number.isSafeInteger(run.id) || run.id < 1 + || !Number.isSafeInteger(run.run_number) || run.run_number < 1 + || (run.run_attempt !== undefined && (!Number.isSafeInteger(run.run_attempt) || run.run_attempt < 1)) + || typeof run.path !== "string" || typeof run.event !== "string" || typeof run.head_sha !== "string") { + throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea returned an invalid exact-head Actions run"); + } + const identity = `${run.id}:${run.run_attempt ?? 0}`; + if (seenRuns.has(identity)) throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea repeated an exact-head Actions run"); + seenRuns.add(identity); + } + }; + addPage(workflowRuns); + if (workflowRuns.length > expectedCount) throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea returned excess exact-head Actions runs"); + for (let page = 2; workflowRuns.length < expectedCount && page <= ACTION_RUNS_MAX_PAGES; page += 1) { + const next = await this.request(path, { query: query(page) }); + if (!Number.isSafeInteger(next.total_count) || next.total_count !== expectedCount || !Array.isArray(next.workflow_runs) + || next.workflow_runs.length > ACTION_RUNS_PAGE_SIZE || next.workflow_runs.length === 0) { + throw new GiteaApiError(502, "GITEA_ACTION_RUNS_TRUNCATED", "Gitea exact-head Actions run pagination was incomplete or changed"); + } + addPage(next.workflow_runs); + workflowRuns.push(...next.workflow_runs); + if (workflowRuns.length > expectedCount) throw new GiteaApiError(502, "GITEA_ACTION_RUNS_INVALID", "Gitea returned excess exact-head Actions runs"); + } + if (workflowRuns.length !== expectedCount) throw new GiteaApiError(502, "GITEA_ACTION_RUNS_TRUNCATED", "Gitea exact-head Actions run list exceeded the bounded pagination limit"); + response = { ...response, workflow_runs: workflowRuns }; + } + + const latestByEvent = new Map(); + for (const run of response.workflow_runs) { + if (!isManagedValidationWorkflowPath(run.path) || !["workflow_dispatch", "pull_request"].includes(run.event)) continue; + if (input.headSha && run.head_sha !== input.headSha) continue; + if (input.pullRequestNumber !== undefined && run.event === "pull_request" + && !actionRunMatchesPullRequestNumber(run, input.pullRequestNumber)) continue; + const group = run.event === "pull_request" + ? `${run.event}:${managedValidationWorkflowPullRequestNumber(run.path) ?? run.pull_requests?.find((pr) => pr.number !== undefined)?.number ?? "unbound"}` + : run.event; + const current = latestByEvent.get(group); + if (!current || runIsNewer(run, current)) latestByEvent.set(group, run); + } + + const jobLists = await Promise.all([...latestByEvent.values()].map(async (run) => ({ + key: `${run.id}:${run.run_attempt ?? 0}`, + jobs: await this.listActionRunJobs(owner, repository, run.id, run.run_attempt), + }))); + const jobsByRun = new Map(jobLists.map(({ key, jobs }) => [key, jobs])); + return { + ...response, + workflow_runs: response.workflow_runs.map((run) => { + const jobs = jobsByRun.get(`${run.id}:${run.run_attempt ?? 0}`); + return jobs ? { ...run, jobs } : run; + }), + }; + } + + private async listActionRunJobs(owner: string, repository: string, runId: number, runAttempt?: number) { + if (!Number.isSafeInteger(runId) || runId < 1 || (runAttempt !== undefined && (!Number.isSafeInteger(runAttempt) || runAttempt < 0))) { + throw new GiteaApiError(502, "GITEA_ACTION_RUN_INVALID", "Gitea returned invalid workflow run identity"); + } + const runPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs/${runId}`; + const attemptPath = Number.isSafeInteger(runAttempt) && runAttempt! > 0 ? `/attempts/${runAttempt}` : ""; + const path = `${runPath}${attemptPath}/jobs`; + const jobs = []; + let expectedCount: number | undefined; + for (let page = 1; page <= ACTION_JOBS_MAX_PAGES; page += 1) { + const result = await this.request(path, { + query: { page, limit: ACTION_JOBS_PAGE_SIZE }, + }); + if (!Number.isSafeInteger(result.total_count) || result.total_count < 0 || !Array.isArray(result.jobs) || result.jobs.length > ACTION_JOBS_PAGE_SIZE) { + throw new GiteaApiError(502, "GITEA_ACTION_JOBS_INVALID", "Gitea returned an invalid Actions job page"); + } + if (expectedCount !== undefined && result.total_count !== expectedCount) { + throw new GiteaApiError(502, "GITEA_ACTION_JOBS_CHANGED", "Gitea Actions job pages changed during validation"); + } + expectedCount = result.total_count; + const seenIds = new Set(jobs.map((job) => job.id)); + for (const job of result.jobs) { + if (!job || typeof job !== "object" || !Number.isSafeInteger(job.id) || job.id < 1 || seenIds.has(job.id)) { + throw new GiteaApiError(502, "GITEA_ACTION_JOBS_INVALID", "Gitea returned invalid or duplicate Actions job identities"); + } + seenIds.add(job.id); + } + jobs.push(...result.jobs); + if (jobs.length > expectedCount) throw new GiteaApiError(502, "GITEA_ACTION_JOBS_INVALID", "Gitea returned duplicate or excess Actions jobs"); + if (jobs.length === expectedCount) return jobs; + if (result.jobs.length === 0) throw new GiteaApiError(502, "GITEA_ACTION_JOBS_TRUNCATED", "Gitea Actions job pagination was incomplete"); + } + throw new GiteaApiError(502, "GITEA_ACTION_JOBS_TRUNCATED", "Gitea Actions job list exceeded the bounded pagination limit"); } listUserActionRunners() { diff --git a/backend/src/integrations/gitea/policy.ts b/backend/src/integrations/gitea/policy.ts index 209574f..28d21eb 100644 --- a/backend/src/integrations/gitea/policy.ts +++ b/backend/src/integrations/gitea/policy.ts @@ -55,54 +55,140 @@ export const assertFileSize = (content: string): void => { }; const actionWorkflowKey = (run: GiteaActionRun): string => - `${run.event}:${run.path.split("@")[0]?.trim() || run.display_title.trim() || `workflow-${run.id}`}`; + `${run.event}:${(typeof run.path === "string" ? run.path.split("@")[0]?.trim() : "") || (typeof run.display_title === "string" ? run.display_title.trim() : "") || `workflow-${run.id}`}`; + +const managedValidationWorkflowPaths = new Set([ + "managed-validation.yml", + ".gitea/workflows/managed-validation.yml", +]); + +type ManagedWorkflowPath = { canonicalBase: boolean; valid: boolean; ref?: string }; + +const parseManagedValidationWorkflowPath = (path: unknown): ManagedWorkflowPath => { + if (typeof path !== "string" || path.length === 0 || path.length > 512) return { canonicalBase: false, valid: false }; + const parts = path.split("@"); + if (!managedValidationWorkflowPaths.has(parts[0] ?? "")) return { canonicalBase: false, valid: false }; + if (parts.length === 1) return { canonicalBase: true, valid: true }; + if (parts.length !== 2) return { canonicalBase: true, valid: false }; + const ref = parts[1] ?? ""; + const pullRequestRef = /^refs\/pull\/([1-9]\d*)\/head$/.exec(ref); + if (pullRequestRef && Number.isSafeInteger(Number(pullRequestRef[1]))) return { canonicalBase: true, valid: true, ref }; + if (/^refs\/heads\/[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref) + && !ref.includes("..") && !ref.includes("//") && !ref.endsWith("/")) { + return { canonicalBase: true, valid: true, ref }; + } + return { canonicalBase: true, valid: false }; +}; + +export const isManagedValidationWorkflowPath = (path: unknown): boolean => parseManagedValidationWorkflowPath(path).valid; + +export const managedValidationWorkflowPullRequestNumber = (path: unknown): number | undefined => { + const info = parseManagedValidationWorkflowPath(path); + const match = info.valid && info.ref ? /^refs\/pull\/([1-9]\d*)\/head$/.exec(info.ref) : null; + const number = match ? Number(match[1]) : undefined; + return Number.isSafeInteger(number) ? number : undefined; +}; + +export const actionRunMatchesPullRequestNumber = (run: GiteaActionRun, expectedNumber: number): boolean => { + if (!Number.isSafeInteger(expectedNumber) || expectedNumber < 1) return false; + if (!run || typeof run !== "object" || !isManagedValidationWorkflowPath(run.path)) return false; + const pathNumber = managedValidationWorkflowPullRequestNumber(run.path); + if (pathNumber !== undefined && pathNumber !== expectedNumber) return false; + const references = run.pull_requests ?? []; + if (!Array.isArray(references)) return false; + const explicitNumbers: number[] = []; + for (const reference of references) { + if (!reference || typeof reference !== "object") return false; + if (reference.number !== undefined) explicitNumbers.push(reference.number); + } + if (explicitNumbers.some((number) => !Number.isSafeInteger(number) || number < 1 || number !== expectedNumber)) return false; + return pathNumber === expectedNumber || explicitNumbers.includes(expectedNumber); +}; // Native Gitea PR runs may omit head_branch. Keep the immutable SHA as the // primary identity, and reject contradictory PR metadata when it is supplied. export const exactHeadActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => runs.filter((run) => { - if (run.head_sha !== headSha) return false; + if (!run || typeof run !== "object" || run.head_sha !== headSha) return false; + if (run.head_branch !== undefined && typeof run.head_branch !== "string") return false; + if (run.event === "pull_request" && run.pull_requests !== undefined && !Array.isArray(run.pull_requests)) return false; + const references = run.event === "pull_request" ? run.pull_requests ?? [] : []; + if (references.some((pr) => { + if (!pr || typeof pr !== "object") return true; + if (pr.number !== undefined && (!Number.isSafeInteger(pr.number) || pr.number < 1)) return true; + if (pr.head !== undefined && (!pr.head || typeof pr.head !== "object")) return true; + if (pr.head?.ref !== undefined && (typeof pr.head.ref !== "string" || pr.head.ref !== branch)) return true; + if (pr.head?.sha !== undefined && (typeof pr.head.sha !== "string" || pr.head.sha !== headSha)) return true; + return false; + })) return false; if (run.head_branch === branch) return true; if (run.head_branch?.trim() || run.event !== "pull_request") return false; - const references = run.pull_requests ?? []; - return references.length === 0 || references.some((pr) => + return references.length === 0 || references.every((pr) => (!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha)); }); const isManagedFullValidationRun = (run: GiteaActionRun): boolean => { - const workflowPath = run.path.split("@")[0]?.trim().toLowerCase() ?? ""; - const title = run.display_title.trim().toLowerCase(); - return (run.event === "workflow_dispatch" || run.event === "pull_request") - && workflowPath.endsWith("/managed-validation.yml") - && title === "managed validation / full"; + if (run.event !== "workflow_dispatch" && run.event !== "pull_request") return false; + const path = parseManagedValidationWorkflowPath(run.path); + if (!path.valid) return false; + if (run.event === "pull_request") return !path.ref || path.ref.startsWith("refs/pull/"); + return !path.ref || path.ref.startsWith("refs/heads/"); }; -export const successfulFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun => { +const hasSuccessfulFullJob = (run: GiteaActionRun, branch: string): boolean => { + if (!Array.isArray(run.jobs)) return false; + const fullJobs = run.jobs.filter((job) => typeof job?.name === "string" && job.name.trim().toLowerCase() === "full"); + if (fullJobs.length !== 1) return false; + const [job] = fullJobs; + if (!job || job.status !== "completed" || job.conclusion !== "success") return false; + if (!Number.isSafeInteger(job.id) || job.id < 1 || job.run_id !== run.id) return false; + if (!Number.isSafeInteger(run.run_attempt) || run.run_attempt! < 1 || job.run_attempt !== run.run_attempt) return false; + if (typeof job.head_sha !== "string" || job.head_sha !== run.head_sha) return false; + if (job.head_branch?.trim() && job.head_branch !== branch) return false; + return true; +}; + +const hasValidRunOrdering = (run: GiteaActionRun): boolean => + Number.isSafeInteger(run.id) && run.id > 0 + && Number.isSafeInteger(run.run_number) && run.run_number > 0 + && (run.run_attempt === undefined || (Number.isSafeInteger(run.run_attempt) && run.run_attempt >= 1)); + +const compareRunFreshness = (left: GiteaActionRun, right: GiteaActionRun): number => + (right.run_number - left.run_number) + || ((right.run_attempt ?? 0) - (left.run_attempt ?? 0)) + || (right.id - left.id); + +const latestManagedFullRun = (runs: GiteaActionRun[], branch: string, headSha: string, expectedPullRequestNumber?: number): GiteaActionRun | undefined => { const fullRuns = exactHeadActionRuns(runs, branch, headSha) .filter(isManagedFullValidationRun) - .sort((left, right) => right.run_number - left.run_number || right.id - left.id); - const latest = fullRuns[0]; - if (!latest || latest.status !== "completed" || latest.conclusion !== "success") { + .filter((run) => expectedPullRequestNumber === undefined || actionRunMatchesPullRequestNumber(run, expectedPullRequestNumber)); + if (fullRuns.some((run) => !hasValidRunOrdering(run))) throw new Error("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); + return fullRuns.sort(compareRunFreshness)[0]; +}; + +export const successfulFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string, expectedPullRequestNumber?: number): GiteaActionRun => { + const latest = latestManagedFullRun(runs, branch, headSha, expectedPullRequestNumber); + if (!latest || latest.status !== "completed" || latest.conclusion !== "success" || !hasSuccessfulFullJob(latest, branch)) { throw new Error("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); } return latest; }; -export const assertSuccessfulPullRequestFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun => { - const latest = exactHeadActionRuns(runs, branch, headSha) - .filter((run) => run.event === "pull_request" && isManagedFullValidationRun(run)) - .sort((left, right) => right.run_number - left.run_number || right.id - left.id)[0]; - if (!latest || latest.status !== "completed" || latest.conclusion !== "success") { +export const assertSuccessfulPullRequestFullActionRun = (runs: GiteaActionRun[], branch: string, headSha: string, expectedPullRequestNumber?: number): GiteaActionRun => { + const latest = latestManagedFullRun(runs.filter((run) => run.event === "pull_request"), branch, headSha, expectedPullRequestNumber); + if (!latest || latest.status !== "completed" || latest.conclusion !== "success" || !hasSuccessfulFullJob(latest, branch)) { throw new Error("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); } - assertSuccessfulActionRuns(runs, branch, headSha); + assertSuccessfulActionRuns(runs, branch, headSha, expectedPullRequestNumber); return latest; }; -export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => { - const exactRuns = exactHeadActionRuns(runs, branch, headSha); +export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string, expectedPullRequestNumber?: number): GiteaActionRun[] => { + const candidates = exactHeadActionRuns(runs, branch, headSha); + if (candidates.some((run) => typeof run.path !== "string" || run.path.length === 0)) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + const exactRuns = candidates; if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); - successfulFullActionRun(exactRuns, branch, headSha); + successfulFullActionRun(exactRuns, branch, headSha, expectedPullRequestNumber); // Gitea retains failed attempts when the same workflow is rerun for an // unchanged revision. Authorization therefore follows the newest run of each @@ -111,7 +197,8 @@ export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: strin for (const run of exactRuns) { const key = actionWorkflowKey(run); const current = latestByWorkflow.get(key); - if (!current || run.run_number > current.run_number || (run.run_number === current.run_number && run.id > current.id)) { + if (!hasValidRunOrdering(run)) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + if (!current || compareRunFreshness(current, run) > 0) { latestByWorkflow.set(key, run); } } diff --git a/backend/src/integrations/gitea/types.ts b/backend/src/integrations/gitea/types.ts index c5c4fc2..d5b137a 100644 --- a/backend/src/integrations/gitea/types.ts +++ b/backend/src/integrations/gitea/types.ts @@ -66,6 +66,7 @@ export type GiteaSearchResult = { export type GiteaActionRun = { id: number; run_number: number; + run_attempt?: number; display_title: string; path: string; event: string; @@ -77,6 +78,7 @@ export type GiteaActionRun = { started_at?: string; completed_at?: string; pull_requests?: Array<{ number?: number; head?: { ref?: string; sha?: string }; base?: { ref?: string; sha?: string } }>; + jobs?: GiteaActionJob[]; }; export type GiteaActionRunList = { total_count: number; workflow_runs: GiteaActionRun[] }; diff --git a/backend/src/mcp/server.ts b/backend/src/mcp/server.ts index 0f83378..cc517ce 100644 --- a/backend/src/mcp/server.ts +++ b/backend/src/mcp/server.ts @@ -777,8 +777,8 @@ export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { const head = await client.getBranch(link!.owner, link!.repository, branch); if (head.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, expected_head_sha); - const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, limit: 30 }); - assertSuccessfulPullRequestFullActionRun(actionRuns.workflow_runs, branch, expected_head_sha); + const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, pullRequestNumber, limit: 30 }); + assertSuccessfulPullRequestFullActionRun(actionRuns.workflow_runs, branch, expected_head_sha, pullRequestNumber); await client.waitForRequiredPullRequestChecks(link!.owner, link!.repository, link!.defaultBranch, expected_head_sha); const mergeReadyPullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber); diff --git a/backend/src/routes/giteaIntegration.ts b/backend/src/routes/giteaIntegration.ts index 78e32c3..8ce79c4 100644 --- a/backend/src/routes/giteaIntegration.ts +++ b/backend/src/routes/giteaIntegration.ts @@ -406,8 +406,8 @@ export const registerGiteaIntegrationRoutes = async (app: FastifyInstance, optio const head = await client.getBranch(link.owner, link.repository, branch); if (head.commit.id !== parsed.data.expectedHeadSha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, parsed.data.expectedHeadSha); - const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: parsed.data.expectedHeadSha, limit: 30 }); - assertSuccessfulPullRequestFullActionRun(actionRuns.workflow_runs, branch, parsed.data.expectedHeadSha); + const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: parsed.data.expectedHeadSha, pullRequestNumber: number, limit: 30 }); + assertSuccessfulPullRequestFullActionRun(actionRuns.workflow_runs, branch, parsed.data.expectedHeadSha, number); await client.waitForRequiredPullRequestChecks(link.owner, link.repository, link.defaultBranch, parsed.data.expectedHeadSha); const mergeReadyPullRequest = await client.getPullRequest(link.owner, link.repository, number); const mergeReadyHead = await client.getBranch(link.owner, link.repository, branch); diff --git a/backend/test/database-autonomy.test.ts b/backend/test/database-autonomy.test.ts index a151cc0..521da99 100644 --- a/backend/test/database-autonomy.test.ts +++ b/backend/test/database-autonomy.test.ts @@ -297,12 +297,27 @@ databaseDescribe("autonomy database write isolation", () => { expect((await getAutonomyState(slug)).activeLease?.token).toBe(resumed.work!.token); const head = "b".repeat(40); let remoteMerged = false; + const fullJob = (runId: number, headSha: string, conclusion = "success") => ({ + id: runId * 10, + html_url: `https://gitea.example.com/runs/${runId}/jobs/${runId * 10}`, + run_id: runId, + name: "full", + labels: [], + run_attempt: 1, + head_sha: headSha, + head_branch: "chatgpt/existing", + status: "completed", + conclusion, + runner_id: 1, + steps: [], + created_at: "2026-10-06T21:33:06Z", + }); vi.spyOn(GiteaClient.prototype, "getPullRequest").mockImplementation(async () => ({ number: 1, title: "Feature", html_url: "https://gitea.example.com/pulls/1", state: remoteMerged ? "closed" : "open", merged: remoteMerged, merge_commit_sha: remoteMerged ? "c".repeat(40) : undefined, head: { ref: "chatgpt/existing", sha: head }, base: { ref: "main", sha: "d".repeat(40) }, mergeable: true })); vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false }); vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" }); const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({}); const actionRuns = vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 2, workflow_runs: [ - { id: 2, run_number: 2, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }, + { id: 2, run_number: 2, run_attempt: 1, display_title: "ci: validate full", path: ".gitea/workflows/managed-validation.yml@refs/pull/1/head", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", pull_requests: [{ number: 1, head: { ref: "chatgpt/existing", sha: head } }], status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2", jobs: [fullJob(2, head)] }, { id: 3, run_number: 3, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/3" }, ] }); vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] }); @@ -325,28 +340,28 @@ databaseDescribe("autonomy database write isolation", () => { expect(synced.isError, JSON.stringify(synced.content)).not.toBe(true); actionRuns.mockResolvedValue({ total_count: 1, workflow_runs: [ - { id: 4, run_number: 4, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: "c".repeat(40), head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/4" }, + { id: 4, run_number: 4, run_attempt: 1, display_title: "ci: validate full", path: ".gitea/workflows/managed-validation.yml@refs/pull/1/head", event: "pull_request", head_sha: "c".repeat(40), head_branch: "chatgpt/existing", pull_requests: [{ number: 1, head: { ref: "chatgpt/existing", sha: "c".repeat(40) } }], status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/4", jobs: [fullJob(4, "c".repeat(40))] }, ] }); const stalePrRun = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); expect(stalePrRun.isError, JSON.stringify(stalePrRun.content)).toBe(true); expect(merge).not.toHaveBeenCalled(); actionRuns.mockResolvedValue({ total_count: 1, workflow_runs: [ - { id: 5, run_number: 5, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/5" }, + { id: 5, run_number: 5, run_attempt: 1, display_title: "ci: validate full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/5", jobs: [fullJob(5, head)] }, ] }); const dispatchOnly = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); expect(dispatchOnly.isError, JSON.stringify(dispatchOnly.content)).toBe(true); expect(merge).not.toHaveBeenCalled(); actionRuns.mockResolvedValue({ total_count: 1, workflow_runs: [ - { id: 6, run_number: 6, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "failure", html_url: "https://gitea.example.com/runs/6" }, + { id: 6, run_number: 6, run_attempt: 1, display_title: "ci: validate full", path: ".gitea/workflows/managed-validation.yml@refs/pull/1/head", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", pull_requests: [{ number: 1, head: { ref: "chatgpt/existing", sha: head } }], status: "completed", conclusion: "failure", html_url: "https://gitea.example.com/runs/6", jobs: [fullJob(6, head, "failure")] }, ] }); const failedPrFull = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); expect(failedPrFull.isError, JSON.stringify(failedPrFull.content)).toBe(true); expect(merge).not.toHaveBeenCalled(); actionRuns.mockResolvedValue({ total_count: 2, workflow_runs: [ - { id: 2, run_number: 2, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }, + { id: 2, run_number: 2, run_attempt: 1, display_title: "ci: validate full", path: ".gitea/workflows/managed-validation.yml@refs/pull/1/head", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", pull_requests: [{ number: 1, head: { ref: "chatgpt/existing", sha: head } }], status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2", jobs: [fullJob(2, head)] }, { id: 3, run_number: 3, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/3" }, ] }); const merged = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); diff --git a/backend/test/gitea-integration.test.ts b/backend/test/gitea-integration.test.ts index 0f7aa85..6016d72 100644 --- a/backend/test/gitea-integration.test.ts +++ b/backend/test/gitea-integration.test.ts @@ -533,7 +533,8 @@ describe("ChatGPT feature delivery approval", () => { it("requires every Gitea Actions workflow for the exact branch head to succeed", () => { const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({ id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", head_sha: headSha, - head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`, + run_attempt: 1, head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`, + jobs: [{ id: id * 10, html_url: `https://gitea.example.com/runs/${id}/jobs/${id * 10}`, run_id: id, name: "full", labels: [], run_attempt: 1, head_sha: headSha, head_branch: "chatgpt/feature", status: "completed", conclusion, runner_id: 1, steps: [], created_at: "2026-10-06T21:33:06Z" }], }); expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2); expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); @@ -546,6 +547,7 @@ describe("ChatGPT feature delivery approval", () => { const run = (overrides: Partial = {}): GiteaActionRun => ({ id: 10, run_number: 10, + run_attempt: 1, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "pull_request", @@ -554,6 +556,7 @@ describe("ChatGPT feature delivery approval", () => { status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/10", + jobs: [{ id: 100, html_url: "https://gitea.example.com/runs/10/jobs/100", run_id: 10, name: "full", labels: [], run_attempt: 1, head_sha: headSha, head_branch: branch, status: "completed", conclusion: "success", runner_id: 1, steps: [], created_at: "2026-10-06T21:33:06Z" }], ...overrides, }); diff --git a/backend/test/native-ci-discovery.test.ts b/backend/test/native-ci-discovery.test.ts index b026421..8689548 100644 --- a/backend/test/native-ci-discovery.test.ts +++ b/backend/test/native-ci-discovery.test.ts @@ -1,15 +1,33 @@ import { describe, expect, it, vi } from "vitest"; import { GiteaClient } from "../src/integrations/gitea/client.js"; -import { assertSuccessfulActionRuns, exactHeadActionRuns } from "../src/integrations/gitea/policy.js"; -import type { GiteaActionRun } from "../src/integrations/gitea/types.js"; +import { assertSuccessfulActionRuns, assertSuccessfulPullRequestFullActionRun, exactHeadActionRuns } from "../src/integrations/gitea/policy.js"; +import type { GiteaActionJob, GiteaActionRun } from "../src/integrations/gitea/types.js"; const branch = "codex/native-ci"; const sha = "a".repeat(40); const context = "CI / build-test (pull_request)"; +const fullJob = (runId: number, headSha = sha, overrides: Partial = {}): GiteaActionJob => ({ + id: runId * 10, + html_url: `https://gitea.example/runs/${runId}/jobs/${runId * 10}`, + run_id: runId, + name: "full", + labels: [], + run_attempt: 1, + head_sha: headSha, + head_branch: branch, + status: "completed", + conclusion: "success", + runner_id: 1, + steps: [], + created_at: "2026-10-06T21:33:06Z", + ...overrides, +}); + const run = (id: number, overrides: Partial = {}): GiteaActionRun => ({ id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", + run_attempt: 1, event: "pull_request", head_sha: sha, head_branch: "", status: "completed", - conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides, + conclusion: "success", html_url: `https://gitea.example/runs/${id}`, jobs: [fullJob(id)], ...overrides, }); describe("native PR validation discovery", () => { @@ -24,6 +42,133 @@ describe("native PR validation discovery", () => { expect(assertSuccessfulActionRuns([matching], branch, sha)).toEqual([matching]); }); + it("accepts Gitea 28 native PR full validation fields and its exact successful full job", () => { + const nativeRun = run(22352, { + run_number: 17, + display_title: "ci: validate QuizMonitor backend tests", + path: "managed-validation.yml@refs/pull/6/head", + head_branch: undefined, + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + jobs: [fullJob(22352, sha, { id: 38976, head_branch: undefined })], + }); + + expect(assertSuccessfulPullRequestFullActionRun([nativeRun], branch, sha)).toBe(nativeRun); + expect(assertSuccessfulPullRequestFullActionRun([nativeRun], branch, sha, 6)).toBe(nativeRun); + }); + + it("binds merge validation to the requested PR number despite a blank native head branch", () => { + const prSix = run(29, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + }); + expect(() => assertSuccessfulPullRequestFullActionRun([prSix], branch, sha, 99)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + expect(() => assertSuccessfulPullRequestFullActionRun([run(30, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 99, head: { ref: branch, sha } }], + head_branch: "", + })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + expect(() => assertSuccessfulPullRequestFullActionRun([run(34, { + path: ".gitea/workflows/managed-validation.yml", + head_branch: "", + })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + expect(() => assertSuccessfulPullRequestFullActionRun([run(35, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }, { number: 99, head: { ref: branch, sha } }], + head_branch: "", + })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it("rejects a newer failed attempt for the requested PR when Gitea omits head_branch", () => { + const successful = run(31, { + run_number: 17, + run_attempt: 1, + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + jobs: [fullJob(31)], + }); + const failed = run(32, { + run_number: 17, + run_attempt: 2, + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + jobs: [fullJob(32, sha, { run_attempt: 2, conclusion: "failure" })], + }); + expect(() => assertSuccessfulPullRequestFullActionRun([successful, failed], branch, sha, 6)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it("keeps failed noncanonical PR workflows in the exact-head merge gate", () => { + const full = run(37, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + }); + const failedQualityWorkflow = run(38, { + path: ".gitea/workflows/security-check.yml@refs/pull/6/head", + conclusion: "failure", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + jobs: undefined, + }); + expect(() => assertSuccessfulPullRequestFullActionRun([full, failedQualityWorkflow], branch, sha, 6)) + .toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it("rejects malformed path metadata without throwing a generic runtime error", () => { + const valid = run(33, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + }); + const malformed = run(34, { event: "push", path: null as unknown as string, head_branch: branch }); + expect(() => assertSuccessfulPullRequestFullActionRun([valid, malformed], branch, sha, 6)) + .toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it.each([ + ["job id", { id: undefined as unknown as number }], + ["run id", { run_id: undefined as unknown as number }], + ["attempt", { run_attempt: undefined as unknown as number }], + ["head SHA", { head_sha: undefined as unknown as string }], + ])("rejects a full job missing its %s identity", (_label, overrides) => { + expect(() => assertSuccessfulPullRequestFullActionRun([run(36, { jobs: [fullJob(36, sha, overrides)] })], branch, sha)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it.each([ + ["missing", []], + ["failed", [fullJob(20, sha, { conclusion: "failure" })]], + ["wrong run", [fullJob(21, sha, { run_id: 999 })]], + ["wrong attempt", [fullJob(22, sha, { run_attempt: 2 })]], + ["wrong head", [fullJob(23, "b".repeat(40))]], + ["duplicate", [fullJob(24), fullJob(24, sha, { id: 241 })]], + ])("rejects a %s canonical full job", (_label, jobs) => { + expect(() => assertSuccessfulPullRequestFullActionRun([run(20, { jobs })], branch, sha)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it("does not let an older successful attempt authorize a newer failed full attempt", () => { + const successful = run(25, { run_number: 17, run_attempt: 1, jobs: [fullJob(25)] }); + const failed = run(26, { + run_number: 17, + run_attempt: 2, + jobs: [fullJob(26, sha, { run_attempt: 2, conclusion: "failure" })], + }); + expect(() => assertSuccessfulPullRequestFullActionRun([successful, failed], branch, sha)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it.each(["untrusted/managed-validation.yml", "other.yml"]) ("does not treat %s or its title as canonical", (path) => { + expect(() => assertSuccessfulPullRequestFullActionRun([run(27, { + path, + display_title: "Managed validation / full", + })], branch, sha)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => { expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); @@ -47,12 +192,108 @@ describe("native PR validation discovery", () => { ], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); - it("queries by exact SHA without the upstream branch filter", async () => { - const fetcher = vi.fn(async () => new Response(JSON.stringify({ total_count: 1, workflow_runs: [run(1)] }))); - await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch).listActionRuns("Jens", "Repo", { branch, headSha: sha }); + it("queries by exact SHA and fetches jobs for the exact run attempt", async () => { + const workflowRun = run(1); + const job = fullJob(1); + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [workflowRun] })); + if (url.pathname.endsWith("/actions/runs/1/attempts/1/jobs")) return new Response(JSON.stringify({ total_count: 1, jobs: [job] })); + return new Response("unexpected request", { status: 404 }); + }); + const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch).listActionRuns("Jens", "Repo", { branch, headSha: sha }); const url = new URL(String(fetcher.mock.calls[0]![0])); expect(url.searchParams.get("head_sha")).toBe(sha); expect(url.searchParams.has("branch")).toBe(false); + expect(result.workflow_runs[0]?.jobs).toEqual([job]); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(new URL(String(fetcher.mock.calls[1]![0])).searchParams.get("limit")).toBe("100"); + }); + + it("only fetches validation jobs for the requested PR when another PR has the same head SHA", async () => { + const unrelated = run(40, { + path: "managed-validation.yml@refs/pull/99/head", + pull_requests: [{ number: 99, head: { ref: branch, sha } }], + head_branch: "", + jobs: undefined, + }); + const expected = run(41, { + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + jobs: undefined, + }); + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 2, workflow_runs: [unrelated, expected] })); + if (url.pathname.endsWith("/actions/runs/41/attempts/1/jobs")) return new Response(JSON.stringify({ total_count: 1, jobs: [fullJob(41)] })); + return new Response("unexpected request", { status: 404 }); + }); + const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) + .listActionRuns("Jens", "Repo", { branch, headSha: sha, pullRequestNumber: 6 }); + expect(result.workflow_runs.find((item) => item.id === 41)?.jobs).toHaveLength(1); + expect(result.workflow_runs.find((item) => item.id === 40)?.jobs).toBeUndefined(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("rejects duplicate job identities across paginated job results", async () => { + const workflowRun = run(42); + const firstPage = Array.from({ length: 100 }, (_, index) => fullJob(42, sha, { id: index + 1 })); + const secondPage = [fullJob(42, sha, { id: 100 })]; + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [workflowRun] })); + if (url.pathname.endsWith("/attempts/1/jobs")) { + return new Response(JSON.stringify({ total_count: 101, jobs: url.searchParams.get("page") === "1" ? firstPage : secondPage })); + } + return new Response("unexpected request", { status: 404 }); + }); + await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) + .listActionRuns("Jens", "Repo", { branch, headSha: sha })).rejects.toMatchObject({ code: "GITEA_ACTION_JOBS_INVALID" }); + }); + + it("paginates the exact-head run inventory so a later failed PR run blocks merge", async () => { + const successful = run(43, { + run_number: 17, + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + jobs: undefined, + }); + const failed = run(44, { + run_number: 18, + path: "managed-validation.yml@refs/pull/6/head", + pull_requests: [{ number: 6, head: { ref: branch, sha } }], + head_branch: "", + conclusion: "failure", + jobs: undefined, + }); + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/actions/runs")) { + return new Response(JSON.stringify({ total_count: 2, workflow_runs: url.searchParams.get("page") === "1" ? [successful] : [failed] })); + } + if (url.pathname.endsWith("/actions/runs/44/attempts/1/jobs")) { + return new Response(JSON.stringify({ total_count: 1, jobs: [fullJob(44, sha, { conclusion: "failure" })] })); + } + return new Response("unexpected request", { status: 404 }); + }); + const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) + .listActionRuns("Jens", "Repo", { branch, headSha: sha, pullRequestNumber: 6 }); + expect(result.workflow_runs).toHaveLength(2); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(() => assertSuccessfulPullRequestFullActionRun(result.workflow_runs, branch, sha, 6)) + .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); + }); + + it("rejects repeated exact-head run identities across API pages", async () => { + const repeated = run(45); + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + return new Response(JSON.stringify({ total_count: 2, workflow_runs: [repeated] })); + }); + await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) + .listActionRuns("Jens", "Repo", { branch, headSha: sha })).rejects.toMatchObject({ code: "GITEA_ACTION_RUNS_INVALID" }); }); it.each([ diff --git a/backend/test/remediation-regressions.test.ts b/backend/test/remediation-regressions.test.ts index 58e2d73..a5850bc 100644 --- a/backend/test/remediation-regressions.test.ts +++ b/backend/test/remediation-regressions.test.ts @@ -15,6 +15,7 @@ const actionRun = (input: { }): GiteaActionRun => ({ id: input.id, run_number: input.id, + run_attempt: 1, display_title: input.workflow ?? "Managed validation / full", path: `.gitea/workflows/${input.workflow ?? "managed-validation.yml"}`, event: input.workflow ? "pull_request" : "workflow_dispatch", @@ -23,6 +24,21 @@ const actionRun = (input: { status: input.status ?? "completed", conclusion: input.conclusion, html_url: `https://gitea.example.com/runs/${input.id}`, + jobs: input.workflow ? [] : [{ + id: input.id * 10, + html_url: `https://gitea.example.com/runs/${input.id}/jobs/${input.id * 10}`, + run_id: input.id, + name: "full", + labels: [], + run_attempt: 1, + head_sha: input.headSha ?? "current-sha", + head_branch: "chatgpt/feature", + status: input.status ?? "completed", + conclusion: input.conclusion, + runner_id: 1, + steps: [], + created_at: "2026-10-06T21:33:06Z", + }], }); const productionEnv = {