diff --git a/apps/explorer/README.md b/apps/explorer/README.md index 13a4a1e..0428cce 100644 --- a/apps/explorer/README.md +++ b/apps/explorer/README.md @@ -8,10 +8,38 @@ During development, select the repository at process startup: QUALITY_PROJECT_ROOT=/absolute/path/to/project pnpm --filter @shiplightai/quality-explorer dev ``` +To preview Release Intelligence against an exact GitHub Actions run, supply a +run URL (recommended) or a numeric run ID. A numeric ID infers the repository +from the project's GitHub `origin` remote: + +```bash +GITHUB_TOKEN="$(gh auth token)" \ +QUALITY_PROJECT_ROOT=/absolute/path/to/project \ +RELEASE_ACTION_RUN=https://github.com/owner/repo/actions/runs/123 \ +pnpm --filter @shiplightai/quality-explorer dev +``` + +The numeric-ID form is equivalent: + +```bash +GITHUB_TOKEN="$(gh auth token)" \ +QUALITY_PROJECT_ROOT=/absolute/path/to/project \ +RELEASE_ACTION_RUN_ID=123 \ +pnpm --filter @shiplightai/quality-explorer dev +``` + +Open . The adapter resolves the run, +analyzes its exact commit in a temporary checkout, and removes that checkout +after rendering. It uses `git archive` when the commit is available locally and +otherwise downloads a private, token-authenticated GitHub archive. Both paths +write only beneath the system temporary directory; they do not fetch into, +modify tracked files in, or write Git metadata to the selected repository. +Optional `RELEASE_ENVIRONMENT` values are `staging` and `production` (the +default); `RELEASE_COMPONENTS` accepts a comma-separated component list. + The server binds to `127.0.0.1:4173`. API handlers ignore client-supplied project paths and always operate on `QUALITY_PROJECT_ROOT`. Repository authoring remains the responsibility of the `quality` agent skill and normal code review. -The current component source lives in the application while standalone parity -is established. Reusable presentation will move incrementally into -`packages/ui`. +The Explorer owns the server adapter and application shell. Reusable, +host-independent Release Intelligence presentation lives in `packages/ui`. diff --git a/apps/explorer/src/app/release-intelligence/layout.tsx b/apps/explorer/src/app/release-intelligence/layout.tsx new file mode 100644 index 0000000..57d685b --- /dev/null +++ b/apps/explorer/src/app/release-intelligence/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { + ReleaseUiHostProvider, + type ReleaseUiHost, +} from "@shiplightai/quality-ui/release-intelligence"; +import "@shiplightai/quality-ui/release-intelligence.css"; + +const host: ReleaseUiHost = { + routeBase: "/release-intelligence", + apiBase: "/api/release-intelligence", +}; + +export default function ReleaseIntelligenceLayout({ + children, +}: { + readonly children: ReactNode; +}): React.ReactElement { + return {children}; +} diff --git a/apps/explorer/src/app/release-intelligence/page.tsx b/apps/explorer/src/app/release-intelligence/page.tsx new file mode 100644 index 0000000..c3ebec5 --- /dev/null +++ b/apps/explorer/src/app/release-intelligence/page.tsx @@ -0,0 +1,27 @@ +import { Alert, Code, Container, Stack, Text, Title } from "@mantine/core"; +import { loadReleasePreview } from "@/lib/release-intelligence/action-run"; +import { ReleasePreview } from "./release-preview"; + +export const dynamic = "force-dynamic"; + +export default async function ReleaseIntelligencePage(): Promise { + try { + const model = await loadReleasePreview(); + return ; + } catch (error) { + return ( + + + Release Intelligence + + {error instanceof Error ? error.message : String(error)} + + + Start Explorer with GITHUB_TOKEN, QUALITY_PROJECT_ROOT, and + either RELEASE_ACTION_RUN or RELEASE_ACTION_RUN_ID. + + + + ); + } +} diff --git a/apps/explorer/src/app/release-intelligence/release-preview.module.css b/apps/explorer/src/app/release-intelligence/release-preview.module.css new file mode 100644 index 0000000..26334ad --- /dev/null +++ b/apps/explorer/src/app/release-intelligence/release-preview.module.css @@ -0,0 +1,17 @@ +.workspace { + display: flex; + height: 100dvh; + min-height: 0; + flex-direction: column; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-lg); + overflow: hidden; +} + +@media (max-width: 61.99em) { + .workspace { + height: auto; + min-height: 100dvh; + overflow: visible; + } +} diff --git a/apps/explorer/src/app/release-intelligence/release-preview.tsx b/apps/explorer/src/app/release-intelligence/release-preview.tsx new file mode 100644 index 0000000..4f9d18d --- /dev/null +++ b/apps/explorer/src/app/release-intelligence/release-preview.tsx @@ -0,0 +1,16 @@ +import { Box } from "@mantine/core"; +import { ReleaseDetail } from "@shiplightai/quality-ui/release-intelligence"; +import type { ReleasePreviewModel } from "@/lib/release-intelligence/action-run"; +import classes from "./release-preview.module.css"; + +export function ReleasePreview({ + model, +}: { + readonly model: ReleasePreviewModel; +}): React.ReactElement { + return ( + + + + ); +} diff --git a/apps/explorer/src/lib/release-intelligence/action-run.test.ts b/apps/explorer/src/lib/release-intelligence/action-run.test.ts new file mode 100644 index 0000000..222ee33 --- /dev/null +++ b/apps/explorer/src/lib/release-intelligence/action-run.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + archiveListingHasUnsafePath, + parseGitHubRemote, + resolveActionRunReference, +} from "./action-run"; + +describe("release action run configuration", () => { + it("accepts a workflow run URL with an attempt", () => { + expect( + resolveActionRunReference( + "https://github.com/ShiplightAI/shipyard/actions/runs/32042052300/attempts/2", + ), + ).toEqual({ + owner: "ShiplightAI", + repo: "shipyard", + runId: "32042052300", + runAttempt: 2, + }); + }); + + it("infers a numeric run's repository from HTTPS or SSH origin", () => { + expect(resolveActionRunReference("42", "git@github.com:ShiplightAI/shipyard.git")).toEqual({ + owner: "ShiplightAI", + repo: "shipyard", + runId: "42", + }); + expect(parseGitHubRemote("https://github.com/ShiplightAI/quality.git")).toEqual({ + owner: "ShiplightAI", + repo: "quality", + }); + expect(parseGitHubRemote("git@github-loggia:ShiplightAI/shipyard.git")).toEqual({ + owner: "ShiplightAI", + repo: "shipyard", + }); + }); + + it("rejects a numeric run without an attributable GitHub repository", () => { + expect(() => resolveActionRunReference("42", "https://example.com/repo.git")).toThrow( + /GitHub origin remote/u, + ); + }); + + it("rejects archive paths and symlink targets that escape the temporary checkout", () => { + expect(archiveListingHasUnsafePath("safe/file\n", "-rw-r--r-- safe/file\n")).toBe(false); + expect(archiveListingHasUnsafePath("../outside\n", "-rw-r--r-- ../outside\n")).toBe(true); + expect( + archiveListingHasUnsafePath( + "safe/link\n", + "lrwxr-xr-x safe/link -> /etc/passwd\n", + ), + ).toBe(true); + expect( + archiveListingHasUnsafePath( + "safe/link\n", + "lrwxr-xr-x safe/link -> ../../outside\n", + ), + ).toBe(true); + }); +}); diff --git a/apps/explorer/src/lib/release-intelligence/action-run.ts b/apps/explorer/src/lib/release-intelligence/action-run.ts new file mode 100644 index 0000000..c049eff --- /dev/null +++ b/apps/explorer/src/lib/release-intelligence/action-run.ts @@ -0,0 +1,622 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + DEFAULT_PRODUCTION_POLICY, + DEFAULT_STAGING_POLICY, + assessBehavior, + buildReleaseSystemFacts, + collectWorkflowEvidence, + evaluateReleasePolicy, + parseWorkflowReference, + type BehaviorAssessment, + type ExpectedBehavior, + type ReleaseDetailView, + type ReleaseEnvironment, + type ReleaseIssueView, + type ReleaseWorkflowEvidence, + type RepositoryFacts, +} from "@shiplightai/quality-core/release-intelligence"; +import { compileReleaseFactsOp } from "@shiplightai/quality-core/release-intelligence/operations"; +import { qualityProjectRoot } from "@/lib/quality-explorer/project-root"; + +const execFileAsync = promisify(execFile); +const releasePreviewGlobal = globalThis as typeof globalThis & { + __qualityReleasePreviewCache?: { + readonly key: string; + readonly promise: Promise; + }; +}; + +interface GitHubRun { + readonly id: number; + readonly run_attempt: number; + readonly name: string; + readonly html_url: string; + readonly head_sha: string; + readonly head_branch: string | null; + readonly conclusion: string | null; + readonly path: string; + readonly created_at: string; + readonly updated_at: string; + readonly repository: { readonly full_name: string }; +} + +interface GitHubJobsResponse { + readonly total_count: number; + readonly jobs: readonly { + readonly id: number; + readonly name: string; + readonly status: string; + readonly conclusion: string | null; + readonly html_url: string; + }[]; +} + +interface GitHubArtifactsResponse { + readonly total_count: number; + readonly artifacts: readonly { + readonly id: number; + readonly name: string; + readonly size_in_bytes: number; + readonly expired: boolean; + }[]; +} + +export interface ReleasePreviewModel + extends ReleaseDetailView { + readonly context: { + readonly workflowName: string; + readonly workflowUrl: string; + readonly workflowRunId: string; + readonly workflowRunAttempt: number; + readonly headBranch: string | null; + readonly environment: ReleaseEnvironment; + readonly components: readonly string[]; + readonly decision: "ALLOW" | "BLOCK"; + }; +} + +export interface ActionRunReference { + readonly owner: string; + readonly repo: string; + readonly runId: string; + readonly runAttempt?: number; +} + +export async function loadReleasePreview(): Promise { + const projectPath = qualityProjectRoot(); + const configuredRun = process.env.RELEASE_ACTION_RUN ?? process.env.RELEASE_ACTION_RUN_ID; + if (!configuredRun) { + throw new Error( + "Set RELEASE_ACTION_RUN to a GitHub Actions run URL, or RELEASE_ACTION_RUN_ID to a numeric run ID.", + ); + } + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("Set GITHUB_TOKEN to a token that can read the workflow run."); + + const cacheKey = JSON.stringify([ + projectPath, + configuredRun, + createHash("sha256").update(token).digest("hex"), + process.env.RELEASE_ENVIRONMENT ?? "production", + process.env.RELEASE_COMPONENTS ?? "", + ]); + if (releasePreviewGlobal.__qualityReleasePreviewCache?.key === cacheKey) { + return releasePreviewGlobal.__qualityReleasePreviewCache.promise; + } + const promise = loadReleasePreviewForConfig(projectPath, configuredRun, token); + releasePreviewGlobal.__qualityReleasePreviewCache = { key: cacheKey, promise }; + try { + return await promise; + } catch (error) { + if (releasePreviewGlobal.__qualityReleasePreviewCache?.promise === promise) { + releasePreviewGlobal.__qualityReleasePreviewCache = undefined; + } + throw error; + } +} + +async function loadReleasePreviewForConfig( + projectPath: string, + configuredRun: string, + token: string, +): Promise { + const remoteUrl = /^\d+$/u.test(configuredRun) + ? await gitOutput(projectPath, ["remote", "get-url", "origin"]) + : undefined; + const reference = resolveActionRunReference(configuredRun, remoteUrl); + const [run, jobsResponse, artifactsResponse] = await Promise.all([ + githubJson( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repo)}/actions/runs/${reference.runId}`, + token, + ), + githubJson( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repo)}/actions/runs/${reference.runId}/jobs?filter=all&per_page=100`, + token, + ), + githubJson( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repo)}/actions/runs/${reference.runId}/artifacts?per_page=100`, + token, + ), + ]); + const expectedRepository = `${reference.owner}/${reference.repo}`; + if (run.repository.full_name.toLowerCase() !== expectedRepository.toLowerCase()) { + throw new Error("GitHub returned a workflow run for a different repository."); + } + if (reference.runAttempt !== undefined && reference.runAttempt !== run.run_attempt) { + throw new Error( + `The requested workflow attempt was ${reference.runAttempt}, but GitHub returned attempt ${run.run_attempt}.`, + ); + } + if (!/^[0-9a-f]{40}$/iu.test(run.head_sha)) { + throw new Error("GitHub returned a workflow run without a full commit SHA."); + } + + const repository = run.repository.full_name; + const workflow: ReleaseWorkflowEvidence = { + runId: String(run.id), + conclusion: run.conclusion, + jobs: jobsResponse.jobs.map((job) => ({ + id: job.id, + name: job.name, + status: job.status, + conclusion: job.conclusion, + url: job.html_url, + })), + artifacts: artifactsResponse.artifacts.map((artifact) => ({ + id: artifact.id, + name: artifact.name, + sizeInBytes: artifact.size_in_bytes, + expired: artifact.expired, + })), + }; + const environment = releaseEnvironment(process.env.RELEASE_ENVIRONMENT); + const components = splitComponents(process.env.RELEASE_COMPONENTS); + + return withCommitCheckout( + projectPath, + reference, + run.head_sha, + token, + async (checkoutPath) => { + const facts = await compileReleaseFactsOp({ + projectPath: checkoutPath, + repository, + commitSha: run.head_sha, + workflowRunId: String(run.id), + analyzedWorkflowPath: workflowPath(run.path), + components, + env: { NODE_ENV: process.env.NODE_ENV, GITHUB_TOKEN: token }, + diagnosticSecrets: [token], + }); + return buildPreviewModel({ + run, + workflow, + facts, + environment, + components, + evidenceTruncated: + jobsResponse.total_count > jobsResponse.jobs.length || + artifactsResponse.total_count > artifactsResponse.artifacts.length, + }); + }, + ); +} + +export function resolveActionRunReference( + value: string, + remoteUrl?: string, +): ActionRunReference { + const parsed = parseWorkflowReference(value.trim()); + if (!parsed) throw new Error("Invalid GitHub Actions run reference."); + if (parsed.owner && parsed.repo) { + return { + owner: parsed.owner, + repo: parsed.repo, + runId: parsed.runId, + ...(parsed.runAttempt === undefined ? {} : { runAttempt: parsed.runAttempt }), + }; + } + if (!parsed.runId) throw new Error("Invalid GitHub Actions run reference."); + const repository = remoteUrl ? parseGitHubRemote(remoteUrl.trim()) : null; + if (!repository) { + throw new Error( + "A numeric RELEASE_ACTION_RUN_ID requires QUALITY_PROJECT_ROOT to have a GitHub origin remote.", + ); + } + return { ...repository, runId: parsed.runId }; +} + +export function parseGitHubRemote( + value: string, +): { readonly owner: string; readonly repo: string } | null { + const match = /^(?:https:\/\/github\.com\/|git@[^:]+:)([^/]+)\/([^/]+?)(?:\.git)?$/iu.exec( + value, + ); + return match ? { owner: match[1]!, repo: match[2]! } : null; +} + +function buildPreviewModel(input: { + readonly run: GitHubRun; + readonly workflow: ReleaseWorkflowEvidence; + readonly facts: RepositoryFacts; + readonly environment: ReleaseEnvironment; + readonly components: readonly string[]; + readonly evidenceTruncated: boolean; +}): ReleasePreviewModel { + const collected = collectWorkflowEvidence(input.workflow, input.facts); + const assessments = assessFacts(input.facts, collected); + const systemFacts = buildReleaseSystemFacts({ + factIntegrity: input.facts.factIntegrity, + evidenceTruncated: input.evidenceTruncated, + integrityDiagnostics: input.facts.integrityDiagnostics, + features: input.facts.features, + }); + const decision = evaluateReleasePolicy({ + policy: + input.environment === "production" ? DEFAULT_PRODUCTION_POLICY : DEFAULT_STAGING_POLICY, + assessments, + systemFacts, + now: new Date(input.run.updated_at), + }); + const assessmentViews = assessments.map((assessment) => ({ + id: assessment.behaviorId, + behaviorSnapshotId: assessment.behaviorId, + status: assessment.status, + runtimeStatus: assessment.runtimeStatus, + observedProof: assessment.observedProof, + missingProof: assessment.missingProof, + reason: assessment.reason, + })); + const systemFactViews = systemFacts.map((fact) => ({ + id: fact.key, + factKey: fact.key, + status: fact.status, + severity: fact.severity, + summary: fact.summary, + exceptionEligible: fact.exceptionEligible, + findings: fact.findings, + })); + const issues = previewIssues(input.facts, assessments, systemFacts, decision); + + return { + repository: input.run.repository.full_name, + release: { + id: `preview:${input.run.id}`, + commitSha: input.run.head_sha, + workflowName: input.run.name, + workflowUrl: input.run.html_url, + workflowRunId: String(input.run.id), + environment: input.environment, + components: input.components, + publishStatus: "unknown", + }, + context: { + workflowName: input.run.name, + workflowUrl: input.run.html_url, + workflowRunId: String(input.run.id), + workflowRunAttempt: input.run.run_attempt, + headBranch: input.run.head_branch, + environment: input.environment, + components: input.components, + decision: decision.decision, + }, + features: input.facts.features.map((feature) => ({ + id: feature.key, + featureKey: feature.key, + name: feature.name, + description: feature.description, + priority: feature.priority, + status: feature.status, + sourceRefs: feature.sourceRefs, + diagnostics: feature.diagnostics, + })), + behaviors: input.facts.features.flatMap((feature) => + feature.behaviors.map((behavior) => ({ + featureId: feature.key, + behavior: { + id: behavior.key, + behaviorKey: behavior.key, + title: behavior.title, + description: behavior.description ?? null, + priority: behavior.priority, + origin: behavior.origin, + reviewStatus: behavior.reviewStatus, + sourceRefs: behavior.sourceRefs, + requiredProof: behavior.requiredProof, + applicable: true, + }, + })), + ), + assessments: assessmentViews, + systemFacts: systemFactViews, + issues, + evidence: collected.map((item) => ({ + id: item.key, + evidenceKey: item.key, + kind: item.kind, + sourceName: item.sourceName, + runtimeStatus: item.runtimeStatus, + identityStatus: item.identityStatus, + collectionStatus: item.collectionStatus, + fileRef: item.fileRef ?? null, + providerRef: item.providerRef ?? null, + archiveRef: null, + contentHash: null, + details: item.details, + })), + assessmentEvidence: collected.flatMap((item) => + item.behaviorKeys.map((behaviorKey) => ({ + assessmentId: behaviorKey, + evidenceRecordId: item.key, + relationship: item.relationship, + reason: item.reason, + })), + ), + rules: decision.rules.map((rule) => ({ + id: rule.ruleKey, + ruleKey: rule.ruleKey, + status: rule.status, + inputs: { + assessmentIds: rule.assessmentIds, + exceptedAssessmentIds: rule.exceptedAssessmentIds, + systemFactKeys: rule.systemFactKeys, + }, + effect: rule.effect, + reason: rule.reason, + })), + exceptions: [], + attempts: [ + { + id: `preview:${input.run.id}:${input.run.run_attempt}`, + attemptNumber: 1, + triggerType: "workflow_gate", + status: decision.decision === "ALLOW" ? "allow" : "block", + analyzerVersion: "local-preview", + factSetHash: null, + decision: decision.decision, + summary: { + featureCount: input.facts.features.length, + behaviorCount: assessments.length, + verifiedCount: assessments.filter((item) => item.status === "verified").length, + issueCount: issues.length, + }, + diagnostics: input.facts.diagnostics, + startedAt: input.run.created_at, + completedAt: input.run.updated_at, + createdAt: input.run.created_at, + }, + ], + }; +} + +function assessFacts( + facts: RepositoryFacts, + evidence: ReturnType, +): BehaviorAssessment[] { + return facts.features.flatMap((feature) => + feature.behaviors.map((behavior) => { + const expected: ExpectedBehavior = { + id: behavior.key, + featureId: feature.key, + title: behavior.title, + ...(behavior.description ? { description: behavior.description } : {}), + priority: behavior.priority, + origin: behavior.origin, + reviewStatus: behavior.reviewStatus, + sourceRefs: behavior.sourceRefs, + requiredProof: behavior.requiredProof, + applicable: true, + }; + return assessBehavior( + expected, + evidence + .filter((item) => item.behaviorKeys.includes(behavior.key)) + .map((item) => ({ + id: item.key, + runtimeStatus: item.runtimeStatus, + identityStatus: item.identityStatus, + collectionStatus: item.collectionStatus, + relationship: item.relationship, + proves: item.proves, + reason: item.reason, + })), + ); + }), + ); +} + +function previewIssues( + facts: RepositoryFacts, + assessments: readonly BehaviorAssessment[], + systemFacts: ReturnType, + decision: ReturnType, +): ReleaseIssueView[] { + const titles = new Map( + facts.features.flatMap((feature) => + feature.behaviors.map((behavior) => [behavior.key, behavior.title] as const), + ), + ); + const behaviorIssues: ReleaseIssueView[] = assessments + .filter((item) => !["verified", "not_applicable"].includes(item.status)) + .map((assessment) => ({ + id: `issue:${assessment.behaviorId}`, + assessmentId: assessment.behaviorId, + systemFactId: null, + kind: assessment.status, + severity: decision.blockingAssessmentIds.includes(assessment.behaviorId) + ? "critical" + : "warning", + title: `${titles.get(assessment.behaviorId) ?? assessment.behaviorId}: ${assessment.status.replaceAll("_", " ")}`, + reason: assessment.reason, + recommendedAction: + assessment.missingProof.length > 0 + ? `Provide exact-run evidence for: ${assessment.missingProof.join(", ")}.` + : "Inspect the linked evidence and correct the failing or conflicting check.", + blocksWithoutException: decision.blockingAssessmentIds.includes(assessment.behaviorId), + })); + const factIssues: ReleaseIssueView[] = systemFacts + .filter((fact) => fact.status === "failed") + .map((fact) => ({ + id: `issue:${fact.key}`, + assessmentId: null, + systemFactId: fact.key, + kind: "analysis_error", + severity: fact.severity, + title: `${fact.key.replaceAll("-", " ")}: incomplete`, + reason: fact.summary, + recommendedAction: + fact.findings[0]?.remediation ?? "Resolve the analysis input problem and run it again.", + blocksWithoutException: fact.severity === "critical", + })); + return [...behaviorIssues, ...factIssues]; +} + +async function withCommitCheckout( + projectPath: string, + repository: Pick, + commitSha: string, + token: string, + operation: (checkoutPath: string) => Promise, +): Promise { + const temporaryRoot = await mkdtemp(join(tmpdir(), "quality-release-preview-")); + const worktreePath = join(temporaryRoot, "checkout"); + try { + if (await hasCommit(projectPath, commitSha)) { + await materializeLocalArchive(temporaryRoot, worktreePath, projectPath, commitSha); + } else { + await materializeGitHubArchive(temporaryRoot, worktreePath, repository, commitSha, token); + } + return await operation(worktreePath); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +async function materializeLocalArchive( + temporaryRoot: string, + checkoutPath: string, + projectPath: string, + commitSha: string, +): Promise { + const archivePath = join(temporaryRoot, "source.tar.gz"); + await mkdir(checkoutPath); + await execFileAsync("git", [ + "-C", + projectPath, + "archive", + "--format=tar.gz", + `--output=${archivePath}`, + commitSha, + ]); + await extractArchive(archivePath, checkoutPath); +} + +async function hasCommit(projectPath: string, commitSha: string): Promise { + try { + await execFileAsync("git", ["-C", projectPath, "cat-file", "-e", `${commitSha}^{commit}`]); + return true; + } catch { + return false; + } +} + +async function materializeGitHubArchive( + temporaryRoot: string, + checkoutPath: string, + repository: Pick, + commitSha: string, + token: string, +): Promise { + const response = await githubResponse( + `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/tarball/${commitSha}`, + token, + ); + const archivePath = join(temporaryRoot, "source.tar.gz"); + await Promise.all([ + mkdir(checkoutPath), + writeFile(archivePath, Buffer.from(await response.arrayBuffer())), + ]); + await extractArchive(archivePath, checkoutPath, 1); +} + +async function extractArchive( + archivePath: string, + checkoutPath: string, + stripComponents = 0, +): Promise { + const [{ stdout: entries }, { stdout: verboseEntries }] = await Promise.all([ + execFileAsync("tar", ["-tzf", archivePath]), + execFileAsync("tar", ["-tvzf", archivePath]), + ]); + if (archiveListingHasUnsafePath(entries, verboseEntries)) { + throw new Error("Refusing to extract an archive with an unsafe path."); + } + + await execFileAsync("tar", [ + "-xzf", + archivePath, + "-C", + checkoutPath, + ...(stripComponents > 0 ? [`--strip-components=${stripComponents}`] : []), + ]); +} + +export function archiveListingHasUnsafePath(entries: string, verboseEntries: string): boolean { + const pathIsUnsafe = (path: string): boolean => + path.startsWith("/") || path.split("/").includes(".."); + if (entries.split("\n").filter(Boolean).some(pathIsUnsafe)) return true; + + return verboseEntries + .split("\n") + .filter((entry) => entry.startsWith("l")) + .some((entry) => { + const separator = entry.lastIndexOf(" -> "); + return separator >= 0 && pathIsUnsafe(entry.slice(separator + 4)); + }); +} + +async function gitOutput(projectPath: string, args: readonly string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", projectPath, ...args]); + return stdout.trim(); +} + +async function githubJson(path: string, token: string): Promise { + const response = await githubResponse(path, token); + return (await response.json()) as T; +} + +async function githubResponse(path: string, token: string): Promise { + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`GitHub API ${path} returned ${response.status}.`); + } + return response; +} + +function workflowPath(value: string): string { + return value.split("@", 1)[0] ?? value; +} + +function releaseEnvironment(value: string | undefined): ReleaseEnvironment { + if (value === undefined || value === "production") return "production"; + if (value === "staging") return "staging"; + throw new Error("RELEASE_ENVIRONMENT must be staging or production."); +} + +function splitComponents(value: string | undefined): readonly string[] { + return value + ? value.split(",").map((component) => component.trim()).filter(Boolean) + : []; +} diff --git a/packages/core/README.md b/packages/core/README.md index add7b91..b810999 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,3 +4,16 @@ Internal workspace package for deterministic scanning, assessment, observations, recommendations, views, and Quality operations. It depends on `@shiplightai/quality-map` and is bundled into the published CLI. + +## Release intelligence + +`@shiplightai/quality-core/release-intelligence` exposes the provider-neutral, +deterministic release assessment contract. It accepts immutable repository and +workflow evidence snapshots and returns behavior assessments and release-policy +decisions. Database persistence, tenant authorization, GitHub App access, +queues, and publish callbacks belong to the embedding host. + +`@shiplightai/quality-core/release-intelligence/operations` contains the +server-side fact compiler. Like the Quality Center operations, it receives a +server-owned project path and a least-privilege environment from the host. It +does not resolve tenants, GitHub installations, or persistence itself. diff --git a/packages/core/package.json b/packages/core/package.json index 3b56e00..0398d23 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,6 +57,16 @@ "import": "./dist/recommendation-export.js", "default": "./dist/recommendation-export.js" }, + "./release-intelligence": { + "types": "./dist/release-intelligence.d.ts", + "import": "./dist/release-intelligence.js", + "default": "./dist/release-intelligence.js" + }, + "./release-intelligence/operations": { + "types": "./dist/release-intelligence-operations.d.ts", + "import": "./dist/release-intelligence-operations.js", + "default": "./dist/release-intelligence-operations.js" + }, "./owner-view": { "types": "./dist/owner-view.d.ts", "import": "./dist/owner-view.js", @@ -131,7 +141,8 @@ "@shiplightai/quality-map": "workspace:^", "adm-zip": "0.5.16", "fast-xml-parser": "4.5.3", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "3.25.76" }, "devDependencies": { "@types/adm-zip": "0.5.7", diff --git a/packages/core/src/release-intelligence/assessment.test.ts b/packages/core/src/release-intelligence/assessment.test.ts new file mode 100644 index 0000000..6453490 --- /dev/null +++ b/packages/core/src/release-intelligence/assessment.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { assessBehavior } from './assessment'; +import type { EvidenceContribution, ExpectedBehavior } from './types'; + +const behavior: ExpectedBehavior = { + id: 'token-reuse', + featureId: 'authentication', + title: 'A password reset token cannot be reused', + priority: 'P0', + origin: 'explicit', + reviewStatus: 'confirmed', + sourceRefs: [], + requiredProof: ['token rejected', 'password unchanged'], + applicable: true, +}; + +function evidence(overrides: Partial = {}): EvidenceContribution { + return { + id: 'e1', + runtimeStatus: 'passed', + identityStatus: 'matched', + collectionStatus: 'available', + relationship: 'direct', + proves: ['token rejected'], + reason: 'API assertion', + ...overrides, + }; +} + +describe('assessBehavior', () => { + it('keeps a passing runtime separate from insufficient proof', () => { + const result = assessBehavior(behavior, [evidence()]); + expect(result.runtimeStatus).toBe('passed'); + expect(result.status).toBe('insufficient_proof'); + expect(result.missingProof).toEqual(['password unchanged']); + }); + + it('marks a failed matching execution as failed rather than missing proof', () => { + const result = assessBehavior(behavior, [evidence({ runtimeStatus: 'failed' })]); + expect(result.status).toBe('failed'); + expect(result.runtimeStatus).toBe('failed'); + }); + + it('ignores mismatched evidence when proving the release commit', () => { + const result = assessBehavior(behavior, [ + evidence({ identityStatus: 'mismatched', proves: behavior.requiredProof }), + ]); + expect(result.status).toBe('insufficient_proof'); + expect(result.evidenceIds).toEqual([]); + }); + + it('combines multiple admissible records to verify all proof facets', () => { + const result = assessBehavior(behavior, [ + evidence(), + evidence({ id: 'e2', proves: ['password unchanged'], reason: 'Database assertion' }), + ]); + expect(result.status).toBe('verified'); + expect(result.missingProof).toEqual([]); + }); + + it('does not assess an inapplicable or rejected behavior', () => { + const result = assessBehavior({ ...behavior, applicable: false }, [evidence()]); + expect(result.status).toBe('not_applicable'); + expect(result.missingProof).toEqual([]); + expect(assessBehavior({ ...behavior, reviewStatus: 'rejected' }, [evidence()]).status).toBe( + 'not_applicable', + ); + }); + + it('keeps conflicting facts distinct from an observed runtime failure', () => { + const result = assessBehavior(behavior, [ + evidence({ relationship: 'conflicting', proves: behavior.requiredProof }), + ]); + + expect(result.status).toBe('conflicting_facts'); + expect(result.runtimeStatus).toBe('passed'); + }); + + it('reports an execution-system error as a check error', () => { + const result = assessBehavior(behavior, [evidence({ runtimeStatus: 'errored' })]); + + expect(result.status).toBe('check_error'); + expect(result.runtimeStatus).toBe('errored'); + }); + + it('does not mask an execution-system error as conflicting facts', () => { + const result = assessBehavior(behavior, [ + evidence({ runtimeStatus: 'errored', relationship: 'conflicting' }), + ]); + + expect(result.status).toBe('check_error'); + expect(result.runtimeStatus).toBe('errored'); + }); +}); diff --git a/packages/core/src/release-intelligence/assessment.ts b/packages/core/src/release-intelligence/assessment.ts new file mode 100644 index 0000000..b39a940 --- /dev/null +++ b/packages/core/src/release-intelligence/assessment.ts @@ -0,0 +1,119 @@ +import type { + BehaviorAssessment, + EvidenceContribution, + ExpectedBehavior, + RuntimeStatus, +} from './types'; + +function aggregateRuntime(evidence: readonly EvidenceContribution[]): RuntimeStatus { + if (evidence.some((item) => item.runtimeStatus === 'failed')) return 'failed'; + if (evidence.some((item) => item.runtimeStatus === 'errored')) return 'errored'; + if (evidence.some((item) => item.runtimeStatus === 'passed')) return 'passed'; + if (evidence.some((item) => item.runtimeStatus === 'skipped')) return 'skipped'; + return 'unknown'; +} +export function assessBehavior( + behavior: ExpectedBehavior, + evidence: readonly EvidenceContribution[], +): BehaviorAssessment { + if (!behavior.applicable || behavior.reviewStatus === 'rejected') { + return result(behavior, 'not_applicable', 'unknown', [], [], [], 'Behavior is not applicable.'); + } + + const admissible = evidence.filter( + (item) => item.identityStatus === 'matched' && item.collectionStatus === 'available', + ); + const runtimeStatus = aggregateRuntime(admissible); + const evidenceIds = admissible.map((item) => item.id); + + if (runtimeStatus === 'failed') { + return result( + behavior, + 'failed', + runtimeStatus, + unique(admissible.flatMap((item) => item.proves)), + [], + evidenceIds, + 'Matching execution evidence observed the required behavior failing.', + ); + } + if (runtimeStatus === 'errored') { + return result( + behavior, + 'check_error', + runtimeStatus, + [], + behavior.requiredProof, + evidenceIds, + 'The check system errored before the required behavior could be assessed.', + ); + } + if (admissible.some((item) => item.relationship === 'conflicting')) { + return result( + behavior, + 'conflicting_facts', + runtimeStatus, + [], + behavior.requiredProof, + evidenceIds, + 'Admissible evidence contains conflicting facts.', + ); + } + + const observed = unique( + admissible + .filter((item) => item.relationship === 'direct' || item.relationship === 'partial') + .flatMap((item) => item.proves), + ); + const missing = behavior.requiredProof.filter((facet) => !observed.includes(facet)); + if (missing.length > 0) { + return result( + behavior, + 'insufficient_proof', + runtimeStatus, + observed, + missing, + evidenceIds, + admissible.length === 0 + ? 'No admissible evidence proves this behavior for the release commit.' + : 'Available evidence does not prove every required outcome.', + ); + } + return result( + behavior, + 'verified', + runtimeStatus, + observed, + [], + evidenceIds, + 'Every required proof facet is supported by admissible evidence.', + ); +} + +function result( + behavior: ExpectedBehavior, + status: BehaviorAssessment['status'], + runtimeStatus: RuntimeStatus, + observedProof: readonly string[], + missingProof: readonly string[], + evidenceIds: readonly string[], + reason: string, +): BehaviorAssessment { + return { + behaviorId: behavior.id, + featureId: behavior.featureId, + priority: behavior.priority, + origin: behavior.origin, + reviewStatus: behavior.reviewStatus, + status, + runtimeStatus, + observedProof, + missingProof, + evidenceIds, + reason, + }; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/core/src/release-intelligence/components.test.ts b/packages/core/src/release-intelligence/components.test.ts new file mode 100644 index 0000000..eda1b6b --- /dev/null +++ b/packages/core/src/release-intelligence/components.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + ReleaseComponentNotFound, + releaseComponentsSchema, + resolveReleaseComponentFeatureIds, +} from './components'; + +describe('release component resolution', () => { + const views = [ + { id: 'web', featureIds: ['accounts', 'shared'] }, + { id: 'api', featureIds: ['public-api', 'shared'] }, + ]; + + it('combines the complete feature scope of every requested component', () => { + expect([...resolveReleaseComponentFeatureIds(views, ['web', 'api'], [])]).toEqual([ + 'accounts', + 'shared', + 'public-api', + ]); + }); + + it('rejects a component that has no matching repository view', () => { + expect(() => resolveReleaseComponentFeatureIds(views, ['web', 'worker'], [])).toThrow( + new ReleaseComponentNotFound(['worker']), + ); + }); + + it('treats an empty component list as every project Feature, including Features outside Views', () => { + expect( + [...resolveReleaseComponentFeatureIds(views, [], ['accounts', 'public-api', 'unassigned'])], + ).toEqual(['accounts', 'public-api', 'unassigned']); + }); + + it('allows an empty component list and normalizes selected components', () => { + expect(releaseComponentsSchema.parse([])).toEqual([]); + expect(releaseComponentsSchema.parse([' api ', 'web', 'api'])).toEqual(['api', 'web']); + expect(releaseComponentsSchema.parse(['éclair', 'zebra', 'Ångström'])).toEqual([ + 'zebra', + 'Ångström', + 'éclair', + ]); + }); +}); diff --git a/packages/core/src/release-intelligence/components.ts b/packages/core/src/release-intelligence/components.ts new file mode 100644 index 0000000..0fc912a --- /dev/null +++ b/packages/core/src/release-intelligence/components.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const releaseComponentsSchema = z + .array(z.string().trim().min(1).max(100)) + .max(50) + .transform((components) => + [...new Set(components)].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + +interface ComponentView { + readonly id: string; + readonly featureIds: readonly string[]; +} +export function resolveReleaseComponentFeatureIds( + views: readonly ComponentView[], + components: readonly string[], + allFeatureIds: readonly string[], +): ReadonlySet { + if (components.length === 0) return new Set(allFeatureIds); + const viewsById = new Map(views.map((view) => [view.id, view])); + const unknown = components.filter((component) => !viewsById.has(component)); + if (unknown.length > 0) throw new ReleaseComponentNotFound(unknown); + + const featureIds = new Set(); + for (const component of components) { + for (const featureId of viewsById.get(component)!.featureIds) featureIds.add(featureId); + } + return featureIds; +} + +export class ReleaseComponentNotFound extends Error { + readonly components: readonly string[]; + + constructor(components: readonly string[]) { + super(`Release component Views not found: ${components.join(', ')}.`); + this.components = [...components]; + } +} diff --git a/packages/core/src/release-intelligence/default-rules.ts b/packages/core/src/release-intelligence/default-rules.ts new file mode 100644 index 0000000..db35574 --- /dev/null +++ b/packages/core/src/release-intelligence/default-rules.ts @@ -0,0 +1,17 @@ +import type { ReleasePolicy } from './policy'; + +export const DEFAULT_PRODUCTION_POLICY: ReleasePolicy = { + environment: 'production', + blockFailedPriorities: ['P0', 'P1'], + blockInsufficientExplicitPriorities: ['P0', 'P1'], + blockCheckErrors: true, + blockConflicts: true, +}; + +export const DEFAULT_STAGING_POLICY: ReleasePolicy = { + environment: 'staging', + blockFailedPriorities: ['P0'], + blockInsufficientExplicitPriorities: ['P0'], + blockCheckErrors: true, + blockConflicts: true, +}; diff --git a/packages/core/src/release-intelligence/evidence.test.ts b/packages/core/src/release-intelligence/evidence.test.ts new file mode 100644 index 0000000..9def674 --- /dev/null +++ b/packages/core/src/release-intelligence/evidence.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { collectWorkflowEvidence } from './evidence'; +import type { ReleaseWorkflowEvidence } from './facts'; +import type { CompiledFeature, RepositoryFacts } from './facts'; + +const workflow: ReleaseWorkflowEvidence = { + runId: '42', + conclusion: 'success', + jobs: [ + { + id: 1, + name: 'checkout contract suite', + status: 'completed', + conclusion: 'success', + url: 'https://github.com/job/1', + }, + ], + artifacts: [{ id: 2, name: 'playwright-report', sizeInBytes: 1, expired: true }], +}; + +const features: CompiledFeature[] = [ + { + key: 'checkout', + name: 'Checkout', + description: '', + priority: 'P0', + status: 'confirmed', + sourceRefs: [], + diagnostics: [], + behaviors: [ + { + key: 'checkout-success', + title: 'Checkout succeeds', + priority: 'P0', + origin: 'explicit', + reviewStatus: 'confirmed', + sourceRefs: [], + requiredProof: ['Checkout succeeds is satisfied at runtime'], + evidenceDeclarations: [ + { key: 'contract', kind: 'contract', sourceName: 'checkout contract' }, + { key: 'unseen', kind: 'e2e', sourceName: 'unseen e2e' }, + ], + }, + ], + }, +]; + +const facts: RepositoryFacts = { + features, + runtimeEvidence: [ + { + key: 'observation:release:contract:1', + evidenceKey: 'contract', + behaviorKey: 'checkout-success', + kind: 'contract', + sourceName: 'checkout contract', + runtimeStatus: 'passed', + identityStatus: 'matched', + collectionStatus: 'available', + providerRef: 'https://github.com/job/1', + testFile: 'tests/checkout.spec.ts', + testCase: 'customer completes checkout', + proves: ['Checkout succeeds is satisfied at runtime'], + reason: 'Exact-run observation passed.', + }, + ], + diagnostics: [], + integrityDiagnostics: [], + factIntegrity: 'complete', + factSet: {}, +}; + +describe('collectWorkflowEvidence', () => { + it('only gives direct proof to a declared input that matches the workflow', () => { + const evidence = collectWorkflowEvidence(workflow, facts); + expect(evidence.find((item) => item.key === 'observation:release:contract:1')).toMatchObject({ + runtimeStatus: 'passed', + collectionStatus: 'available', + relationship: 'direct', + behaviorKeys: ['checkout-success'], + details: { + evidenceKey: 'contract', + workflowRunId: '42', + testFile: 'tests/checkout.spec.ts', + testCase: 'customer completes checkout', + }, + }); + expect(evidence.find((item) => item.key === 'declared:checkout-success:unseen')).toMatchObject({ + runtimeStatus: 'unknown', + collectionStatus: 'missing', + relationship: 'indirect', + proves: [], + }); + }); + + it('keeps missing declarations distinct when behaviors reuse a declaration key', () => { + const repeated = { + ...facts, + features: [ + { + ...features[0]!, + behaviors: [ + features[0]!.behaviors[0]!, + { + ...features[0]!.behaviors[0]!, + key: 'checkout-refund', + title: 'Checkout can be refunded', + evidenceDeclarations: [ + { key: 'unseen', kind: 'e2e', sourceName: 'unseen refund e2e' }, + ], + }, + ], + }, + ], + } satisfies RepositoryFacts; + + const missing = collectWorkflowEvidence(workflow, repeated).filter( + (item) => item.collectionStatus === 'missing', + ); + expect(missing.map((item) => item.key)).toEqual([ + 'declared:checkout-success:unseen', + 'declared:checkout-refund:unseen', + ]); + }); + + it('keeps expired artifacts visible without treating them as proof', () => { + expect( + collectWorkflowEvidence(workflow, facts).find((item) => item.key === 'artifact:2'), + ).toMatchObject({ + collectionStatus: 'expired', + runtimeStatus: 'unknown', + behaviorKeys: [], + providerRef: 'artifact:2', + }); + }); +}); diff --git a/packages/core/src/release-intelligence/evidence.ts b/packages/core/src/release-intelligence/evidence.ts new file mode 100644 index 0000000..a68b6e9 --- /dev/null +++ b/packages/core/src/release-intelligence/evidence.ts @@ -0,0 +1,126 @@ +import type { ReleaseWorkflowEvidence } from './facts'; +import type { CompiledBehavior, RepositoryFacts } from './facts'; +import type { EvidenceContribution } from './types'; + +export interface CollectedEvidence { + readonly key: string; + readonly kind: string; + readonly sourceName: string; + readonly runtimeStatus: EvidenceContribution['runtimeStatus']; + readonly identityStatus: EvidenceContribution['identityStatus']; + readonly collectionStatus: EvidenceContribution['collectionStatus']; + readonly providerRef?: string; + readonly fileRef?: { readonly path: string }; + readonly details: Record; + readonly behaviorKeys: readonly string[]; + readonly relationship: EvidenceContribution['relationship']; + readonly proves: readonly string[]; + readonly reason: string; +} +export function collectWorkflowEvidence( + workflow: ReleaseWorkflowEvidence, + facts: RepositoryFacts, +): readonly CollectedEvidence[] { + const allBehaviors = facts.features.flatMap((feature) => feature.behaviors); + const declared = allBehaviors.flatMap((behavior) => + behavior.evidenceDeclarations.map((item) => { + const observations = facts.runtimeEvidence.filter( + (evidence) => evidence.behaviorKey === behavior.key && evidence.evidenceKey === item.key, + ); + return observations.length > 0 + ? observations.map( + (observed): CollectedEvidence => ({ + key: observed.key, + kind: observed.kind, + sourceName: observed.sourceName, + runtimeStatus: observed.runtimeStatus, + identityStatus: observed.identityStatus, + collectionStatus: observed.collectionStatus, + providerRef: observed.providerRef, + fileRef: observed.fileRef, + details: { + evidenceKey: observed.evidenceKey, + workflowRunId: workflow.runId, + ...(observed.testFile ? { testFile: observed.testFile } : {}), + ...(observed.testCase ? { testCase: observed.testCase } : {}), + }, + behaviorKeys: [observed.behaviorKey], + relationship: 'direct', + proves: observed.proves, + reason: observed.reason, + }), + ) + : [missingDeclaration(behavior, item)]; + }), + ); + const jobs = workflow.jobs.map( + (job): CollectedEvidence => ({ + key: `job:${job.id}`, + kind: 'workflow_job', + sourceName: job.name, + runtimeStatus: runtime(job.conclusion), + identityStatus: 'matched', + collectionStatus: 'available', + providerRef: job.url, + details: { status: job.status, conclusion: job.conclusion }, + behaviorKeys: [], + relationship: 'indirect', + proves: [], + reason: 'Workflow operational evidence; no declared Behavior mapping was found.', + }), + ); + const artifacts = workflow.artifacts.map( + (artifact): CollectedEvidence => ({ + key: `artifact:${artifact.id}`, + kind: 'workflow_artifact', + sourceName: artifact.name, + runtimeStatus: artifact.expired ? 'unknown' : runtime(workflow.conclusion), + identityStatus: 'matched', + collectionStatus: artifact.expired ? 'expired' : 'available', + providerRef: `artifact:${artifact.id}`, + details: { + artifactId: artifact.id, + sizeInBytes: artifact.sizeInBytes, + expired: artifact.expired, + }, + behaviorKeys: [], + relationship: 'indirect', + proves: [], + reason: artifact.expired + ? 'GitHub reports this artifact as expired.' + : 'Artifact is available but has no declared Behavior mapping.', + }), + ); + return [...declared.flat(), ...jobs, ...artifacts]; +} + +function missingDeclaration( + behavior: CompiledBehavior, + declaration: CompiledBehavior['evidenceDeclarations'][number], +): CollectedEvidence { + return { + // Declaration keys are scoped to a Behavior in repository facts. Include + // both scopes so two Behaviors can safely reuse a conventional key such as + // "e2e" without collapsing their persisted evidence records. + key: `declared:${behavior.key}:${declaration.key}`, + kind: declaration.kind, + sourceName: declaration.sourceName, + runtimeStatus: 'unknown', + identityStatus: 'matched', + collectionStatus: 'missing', + ...(declaration.path ? { fileRef: { path: declaration.path } } : {}), + details: { declaration }, + behaviorKeys: [behavior.key], + relationship: 'indirect', + proves: [], + reason: 'No exact-run observation was available for this declared evidence.', + }; +} + +function runtime(conclusion: string | null): EvidenceContribution['runtimeStatus'] { + if (conclusion === 'success') return 'passed'; + if (conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled') + return 'failed'; + if (conclusion === 'skipped') return 'skipped'; + return 'unknown'; +} diff --git a/packages/core/src/release-intelligence/facts.ts b/packages/core/src/release-intelligence/facts.ts new file mode 100644 index 0000000..5c0fec0 --- /dev/null +++ b/packages/core/src/release-intelligence/facts.ts @@ -0,0 +1,78 @@ +import type { BehaviorOrigin, BehaviorPriority, RuntimeStatus, SourceReference } from "./types"; + +export interface CompiledFeature { + readonly key: string; + readonly name: string; + readonly description: string; + readonly priority: BehaviorPriority; + readonly status: "confirmed" | "proposed"; + readonly sourceRefs: readonly SourceReference[]; + readonly diagnostics: readonly Record[]; + readonly behaviors: readonly CompiledBehavior[]; +} +export interface CompiledBehavior { + readonly key: string; + readonly title: string; + readonly description?: string; + readonly priority: BehaviorPriority; + readonly origin: BehaviorOrigin; + readonly reviewStatus: "confirmed" | "proposed"; + readonly sourceRefs: readonly SourceReference[]; + readonly requiredProof: readonly string[]; + readonly evidenceDeclarations: readonly { + readonly key: string; + readonly kind: string; + readonly sourceName: string; + readonly path?: string; + readonly url?: string; + readonly command?: string; + }[]; +} + +export interface RuntimeFactEvidence { + readonly key: string; + readonly evidenceKey: string; + readonly behaviorKey: string; + readonly kind: string; + readonly sourceName: string; + readonly runtimeStatus: RuntimeStatus; + readonly identityStatus: "matched" | "mismatched" | "unverifiable"; + readonly collectionStatus: "available" | "expired" | "missing" | "parse_error"; + readonly providerRef?: string; + readonly fileRef?: { readonly path: string }; + readonly testFile?: string; + readonly testCase?: string; + readonly proves: readonly string[]; + readonly reason: string; +} + +export interface RepositoryFacts { + readonly features: readonly CompiledFeature[]; + readonly runtimeEvidence: readonly RuntimeFactEvidence[]; + readonly diagnostics: readonly Record[]; + readonly integrityDiagnostics: readonly Record[]; + readonly factIntegrity: "complete" | "incomplete"; + readonly factSet: Record; +} + +/** + * Provider-neutral workflow evidence. A hosting application resolves and + * authorizes the workflow before passing this immutable snapshot to the engine. + */ +export interface ReleaseWorkflowEvidence { + readonly runId: string; + readonly conclusion: string | null; + readonly jobs: readonly { + readonly id: number; + readonly name: string; + readonly status: string; + readonly conclusion: string | null; + readonly url: string; + }[]; + readonly artifacts: readonly { + readonly id: number; + readonly name: string; + readonly sizeInBytes: number; + readonly expired: boolean; + }[]; +} diff --git a/packages/core/src/release-intelligence/fix-prompt.test.ts b/packages/core/src/release-intelligence/fix-prompt.test.ts new file mode 100644 index 0000000..c6bf933 --- /dev/null +++ b/packages/core/src/release-intelligence/fix-prompt.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { buildReleaseIssueFixPrompt, buildReleaseIssueFixPromptForView } from './fix-prompt'; + +const issue = { + kind: 'insufficient_proof', + title: 'Checkout rejects expired cards: insufficient proof', + reason: 'The workflow did not provide evidence for the declined-card response.', + recommendedAction: 'Add or restore exact-commit runtime evidence.', +}; + +describe('buildReleaseIssueFixPrompt', () => { + it('gives an AI the release, behavior, source, and proof context needed to fix an issue', () => { + const prompt = buildReleaseIssueFixPrompt({ + repository: 'shiplight/example', + commitSha: 'abc123def456', + issue, + behavior: { + featureName: 'Checkout', + title: 'Checkout rejects expired cards', + description: 'An expired card is rejected without creating an order.', + sourceRefs: [{ path: 'specs/checkout.md', startLine: 12, endLine: 18 }], + missingProof: ['declined response', 'no order created'], + }, + }); + + expect(prompt).toContain( + 'Add the missing verification evidence: Checkout rejects expired cards', + ); + expect(prompt).toContain('Context: Checkout / Checkout rejects expired cards'); + expect(prompt).toContain('Files: specs/checkout.md:12-18'); + expect(prompt).toContain('Missing proof: declined response, no order created'); + expect(prompt).toContain('Analyzed at: shiplight/example@abc123def456'); + expect(prompt).not.toContain('Workflow:'); + expect(prompt).not.toContain('Severity:'); + expect(prompt).toContain( + 'Do not change product behavior unless reproduction shows it is wrong.', + ); + }); + + it('includes every System Fact finding and affected path in an analysis-input prompt', () => { + const prompt = buildReleaseIssueFixPrompt({ + repository: 'shiplight/example', + commitSha: 'abc123def456', + issue: { ...issue, kind: 'analysis_error' }, + systemFact: { + findings: [ + { + code: 'MISSING_EVIDENCE_FILE', + message: 'A declared evidence file does not exist.', + remediation: 'Restore the file or update the declaration.', + declarationPath: 'quality-map.yaml', + affectedPath: 'tests/checkout.yaml', + }, + ], + }, + }); + + expect(prompt).toContain('Detail: A declared evidence file does not exist.'); + expect(prompt).toContain('Files: quality-map.yaml, tests/checkout.yaml'); + expect(prompt).toContain('Fix: Restore the file or update the declaration.'); + expect(prompt).toContain('Repair the release-analysis input:'); + expect(prompt).not.toContain('MISSING_EVIDENCE_FILE'); + }); + + it('tells an AI to fix product code only for an observed behavior failure', () => { + const prompt = buildReleaseIssueFixPrompt({ + repository: 'shiplight/example', + commitSha: 'abc123def456', + issue: { ...issue, kind: 'failed' }, + }); + + expect(prompt).toContain('Fix the failing product behavior:'); + expect(prompt).toContain('make the smallest correct product fix'); + expect(prompt).not.toContain('Add the missing verification evidence:'); + }); + + it.each([ + [ + 'conflicting_facts', + 'Resolve the conflicting implementation or evidence:', + 'resolve the conflict without discarding valid evidence', + ], + [ + 'check_error', + 'Repair the verification check:', + 'repair the check without weakening its assertions', + ], + [ + 'new_issue_kind', + 'Address this release issue:', + 'make the smallest correct fix without weakening assertions', + ], + ])( + 'gives %s issues an accurate task and verification instruction', + (kind, task, verification) => { + const prompt = buildReleaseIssueFixPrompt({ + repository: 'shiplight/example', + commitSha: 'abc123def456', + issue: { ...issue, kind }, + }); + + expect(prompt).toContain(task); + expect(prompt).toContain(verification); + }, + ); +}); + +describe('buildReleaseIssueFixPromptForView', () => { + it('joins a persisted issue to its behavior without platform record types', () => { + const prompt = buildReleaseIssueFixPromptForView( + { + repository: 'shiplight/example', + release: { commitSha: 'a'.repeat(40) }, + features: [ + { + id: 'feature-1', + featureKey: 'checkout', + name: 'Checkout', + description: '', + priority: 'P0', + status: 'confirmed', + sourceRefs: [], + diagnostics: [], + }, + ], + behaviors: [ + { + featureId: 'feature-1', + behavior: { + id: 'behavior-1', + behaviorKey: 'checkout:succeeds', + title: 'Checkout succeeds', + description: null, + priority: 'P0', + origin: 'explicit', + reviewStatus: 'confirmed', + sourceRefs: [{ path: 'specs/checkout.md', startLine: 12 }], + requiredProof: ['checkout succeeds'], + applicable: true, + }, + }, + ], + assessments: [ + { + id: 'assessment-1', + behaviorSnapshotId: 'behavior-1', + status: 'insufficient_proof', + runtimeStatus: 'unknown', + observedProof: [], + missingProof: ['checkout succeeds'], + reason: 'No evidence.', + }, + ], + issues: [], + assessmentEvidence: [], + systemFacts: [], + evidence: [], + rules: [], + }, + { ...issue, id: 'issue-1', assessmentId: 'assessment-1', systemFactId: null, severity: 'critical', blocksWithoutException: true }, + ); + + expect(prompt).toContain('Context: Checkout / Checkout succeeds'); + expect(prompt).toContain('Files: specs/checkout.md:12'); + }); +}); diff --git a/packages/core/src/release-intelligence/fix-prompt.ts b/packages/core/src/release-intelligence/fix-prompt.ts new file mode 100644 index 0000000..8f29314 --- /dev/null +++ b/packages/core/src/release-intelligence/fix-prompt.ts @@ -0,0 +1,150 @@ +import type { ReleaseSystemFactFinding, SourceReference } from './types'; +import type { ReleaseIssueEvidenceView, ReleaseIssueView } from './read-models'; +import { parseReleaseSystemFactFindings } from './system-facts'; + +export interface ReleaseIssueFixPromptInput { + readonly repository: string; + readonly commitSha: string; + readonly issue: { + readonly kind: string; + readonly title: string; + readonly reason: string; + readonly recommendedAction: string; + }; + readonly behavior?: { + readonly featureName: string; + readonly title: string; + readonly description?: string; + readonly sourceRefs: readonly SourceReference[]; + readonly missingProof: readonly string[]; + }; + readonly systemFact?: { + readonly findings: readonly ReleaseSystemFactFinding[]; + }; +} + +export function buildReleaseIssueFixPrompt(input: ReleaseIssueFixPromptInput): string { + const details = input.behavior + ? behaviorDetails(input.behavior) + : input.systemFact + ? systemFactDetails(input.systemFact) + : []; + + return [ + `${taskForIssue(input.issue.kind)}: ${input.issue.title}`, + '', + `Problem: ${input.issue.reason}`, + ...details, + ...(!input.systemFact ? [`Fix: ${input.issue.recommendedAction}`] : []), + `Analyzed at: ${input.repository}@${input.commitSha}`, + '', + verificationForIssue(input.issue.kind), + ].join('\n'); +} + +/** Builds a fix prompt from the provider-neutral persisted detail projection. */ +export function buildReleaseIssueFixPromptForView( + detail: ReleaseIssueEvidenceView, + issue: ReleaseIssueView, +): string { + const assessment = issue.assessmentId + ? detail.assessments.find((item) => item.id === issue.assessmentId) + : undefined; + const behaviorItem = assessment + ? detail.behaviors.find((item) => item.behavior.id === assessment.behaviorSnapshotId) + : undefined; + const feature = behaviorItem + ? detail.features.find((item) => item.id === behaviorItem.featureId) + : undefined; + const systemFact = issue.systemFactId + ? detail.systemFacts.find((item) => item.id === issue.systemFactId) + : undefined; + + return buildReleaseIssueFixPrompt({ + repository: detail.repository, + commitSha: detail.release.commitSha, + issue, + ...(assessment && behaviorItem && feature + ? { + behavior: { + featureName: feature.name, + title: behaviorItem.behavior.title, + description: behaviorItem.behavior.description ?? undefined, + sourceRefs: behaviorItem.behavior.sourceRefs, + missingProof: assessment.missingProof, + }, + } + : {}), + ...(systemFact + ? { systemFact: { findings: parseReleaseSystemFactFindings(systemFact.findings) } } + : {}), + }); +} + +function taskForIssue(kind: string): string { + switch (kind) { + case 'failed': + return 'Fix the failing product behavior'; + case 'insufficient_proof': + return 'Add the missing verification evidence'; + case 'conflicting_facts': + return 'Resolve the conflicting implementation or evidence'; + case 'check_error': + return 'Repair the verification check'; + case 'analysis_error': + return 'Repair the release-analysis input'; + default: + return 'Address this release issue'; + } +} + +function verificationForIssue(kind: string): string { + switch (kind) { + case 'failed': + return 'Reproduce the failure, add or update a regression test, make the smallest correct product fix, and run the relevant checks.'; + case 'insufficient_proof': + return 'Confirm the behavior at HEAD and add focused proof without weakening assertions. Do not change product behavior unless reproduction shows it is wrong.'; + case 'conflicting_facts': + return 'Determine the intended behavior from repository-owned sources, resolve the conflict without discarding valid evidence, and run the relevant checks.'; + case 'check_error': + return 'Reproduce the check error, repair the check without weakening its assertions, and run it successfully.'; + case 'analysis_error': + return 'Validate the referenced paths and declarations at HEAD, make the smallest input correction, and rerun the relevant analysis checks.'; + default: + return 'Confirm the issue at HEAD, make the smallest correct fix without weakening assertions, and run the relevant checks.'; + } +} + +function behaviorDetails(input: NonNullable): string[] { + return [ + `Context: ${input.featureName} / ${input.title}`, + ...(input.description ? [`Expected: ${input.description}`] : []), + ...list('Files', input.sourceRefs.map(formatSourceReference)), + ...list('Missing proof', input.missingProof), + ]; +} + +function systemFactDetails(input: NonNullable): string[] { + return input.findings.flatMap((finding, index) => [ + `${input.findings.length > 1 ? `Detail ${index + 1}` : 'Detail'}: ${finding.message}`, + ...(finding.featureName || finding.behaviorTitle + ? [`Context: ${[finding.featureName, finding.behaviorTitle].filter(Boolean).join(' / ')}`] + : []), + ...list( + 'Files', + [finding.declarationPath, finding.affectedPath].filter( + (path): path is string => path !== undefined, + ), + ), + `Fix${input.findings.length > 1 ? ` ${index + 1}` : ''}: ${finding.remediation}`, + ]); +} + +function list(label: string, values: readonly string[]): string[] { + return values.length > 0 ? [`${label}: ${values.join(', ')}`] : []; +} + +function formatSourceReference(source: SourceReference): string { + if (source.startLine === undefined) return source.path; + return `${source.path}:${source.startLine}${source.endLine ? `-${source.endLine}` : ''}`; +} diff --git a/packages/core/src/release-intelligence/github-links.test.ts b/packages/core/src/release-intelligence/github-links.test.ts new file mode 100644 index 0000000..61c713a --- /dev/null +++ b/packages/core/src/release-intelligence/github-links.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { githubCommitUrl, githubSourceUrl } from './github-links'; + +const sha = '1234567890abcdef1234567890abcdef12345678'; + +describe('release intelligence GitHub links', () => { + it('pins commit and source links to the full immutable SHA', () => { + expect(githubCommitUrl('ShiplightAI/shipyard', sha)).toBe( + `https://github.com/ShiplightAI/shipyard/commit/${sha}`, + ); + expect( + githubSourceUrl('ShiplightAI/shipyard', sha, { + path: 'specs/password reset/spec.md', + startLine: 42, + endLine: 47, + }), + ).toBe( + `https://github.com/ShiplightAI/shipyard/blob/${sha}/specs/password%20reset/spec.md#L42-L47`, + ); + }); + + it('rejects moving refs and paths that escape the repository', () => { + expect(() => githubCommitUrl('ShiplightAI/shipyard', 'main')).toThrow(/full 40-character/); + expect(() => githubSourceUrl('ShiplightAI/shipyard', sha, { path: '../secret' })).toThrow( + /repository-relative/, + ); + }); +}); diff --git a/packages/core/src/release-intelligence/github-links.ts b/packages/core/src/release-intelligence/github-links.ts new file mode 100644 index 0000000..23bfe95 --- /dev/null +++ b/packages/core/src/release-intelligence/github-links.ts @@ -0,0 +1,39 @@ +import type { SourceReference } from './types'; + +const FULL_SHA = /^[0-9a-f]{40}$/i; + +function encodePath(path: string): string { + return path + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +export function githubCommitUrl(repoFullName: string, commitSha: string): string { + assertRepoAndCommit(repoFullName, commitSha); + return `https://github.com/${repoFullName}/commit/${commitSha}`; +} + +export function githubSourceUrl( + repoFullName: string, + commitSha: string, + source: SourceReference, +): string { + assertRepoAndCommit(repoFullName, commitSha); + if (source.path.startsWith('/') || source.path.split('/').includes('..')) { + throw new Error('source path must be repository-relative'); + } + const start = source.startLine; + const end = source.endLine; + if ((start !== undefined && start < 1) || (end !== undefined && (start === undefined || end < start))) { + throw new Error('source line range is invalid'); + } + const anchor = start === undefined ? '' : end === undefined ? `#L${start}` : `#L${start}-L${end}`; + return `https://github.com/${repoFullName}/blob/${commitSha}/${encodePath(source.path)}${anchor}`; +} + +function assertRepoAndCommit(repoFullName: string, commitSha: string): void { + const [owner, repo, extra] = repoFullName.split('/'); + if (!owner || !repo || extra) throw new Error('repository must be owner/name'); + if (!FULL_SHA.test(commitSha)) throw new Error('commit SHA must be a full 40-character SHA'); +} diff --git a/packages/core/src/release-intelligence/index.ts b/packages/core/src/release-intelligence/index.ts new file mode 100644 index 0000000..3a74f68 --- /dev/null +++ b/packages/core/src/release-intelligence/index.ts @@ -0,0 +1,12 @@ +export * from "./assessment"; +export * from "./components"; +export * from "./default-rules"; +export * from "./evidence"; +export * from "./facts"; +export * from "./fix-prompt"; +export * from "./github-links"; +export * from "./policy"; +export * from "./read-models"; +export * from "./system-facts"; +export * from "./types"; +export * from "./workflow-reference"; diff --git a/packages/core/src/release-intelligence/operations.test.ts b/packages/core/src/release-intelligence/operations.test.ts new file mode 100644 index 0000000..351abc2 --- /dev/null +++ b/packages/core/src/release-intelligence/operations.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + diagnosticsForSelectedQualityMaps, + isUsableSelectedQualityMap, + observationProfileTargetsRepository, + observationSelectionForProfile, + redactDiagnosticSecrets, +} from "./operations"; + +describe("release fact compilation helpers", () => { + it("retains diagnostics that make a selected quality map partial", () => { + const diagnostic = { + severity: "warning", + code: "UNKNOWN_FIELD", + message: "Unknown quality-map field is ignored.", + mapPath: ".quality/evidence/web/quality-map.yaml", + }; + + expect( + diagnosticsForSelectedQualityMaps([{ diagnostics: [diagnostic] }, { diagnostics: [] }]), + ).toEqual([diagnostic]); + }); + + it("requires a usable graph for valid and partial quality maps", () => { + expect(isUsableSelectedQualityMap({ status: "valid", graph: {} })).toBe(true); + expect(isUsableSelectedQualityMap({ status: "partial", graph: {} })).toBe(true); + expect(isUsableSelectedQualityMap({ status: "partial" })).toBe(false); + expect(isUsableSelectedQualityMap({ status: "invalid", graph: {} })).toBe(false); + }); + + it("matches only the release repository using case-insensitive GitHub coordinates", () => { + expect( + observationProfileTargetsRepository("ShiplightAI/shipyard", "shiplightai/SHIPYARD"), + ).toBe(true); + expect( + observationProfileTargetsRepository("ShiplightAI/private", "ShiplightAI/shipyard"), + ).toBe(false); + expect(observationProfileTargetsRepository("invalid", "ShiplightAI/shipyard")).toBe(false); + expect( + observationProfileTargetsRepository("ShiplightAI/shipyard/extra", "ShiplightAI/shipyard"), + ).toBe(false); + }); + + it("pins the analyzed workflow profile to its immutable run", () => { + const commitSha = "58a543249c8035979b06e6de564fd496b606aed3"; + + expect( + observationSelectionForProfile({ + profileId: "release-apps-to-staging", + profileWorkflow: "release-apps-to-staging.yml", + analyzedWorkflowPath: ".github/workflows/release-apps-to-staging.yml", + workflowRunId: "32042052300", + commitSha, + }), + ).toEqual({ + commit: commitSha, + profiles: [ + { + profileId: "release-apps-to-staging", + runId: 32042052300, + commit: commitSha, + }, + ], + }); + expect( + observationSelectionForProfile({ + profileId: "ci-quality-evidence", + profileWorkflow: "ci.yml", + analyzedWorkflowPath: ".github/workflows/release-apps-to-staging.yml", + workflowRunId: "32042052300", + commitSha, + }), + ).toEqual({ commit: commitSha }); + }); + + it("redacts injected and provider-shaped secrets without mutating diagnostics", () => { + const token = "github_pat_abcdefghijklmnopqrstuvwxyz123456"; + const cliToken = "ghc_abcdefghijklmnopqrstuvwxyz123456"; + const diagnostics = [ + { + message: `Request failed with secret-token, ${token}, and ${cliToken}`, + context: { authorization: "Bearer secret-token" }, + }, + ]; + + expect(redactDiagnosticSecrets(diagnostics, ["secret-token"])).toEqual([ + { + message: "Request failed with [REDACTED], [REDACTED], and [REDACTED]", + context: { authorization: "Bearer [REDACTED]" }, + }, + ]); + expect(diagnostics[0]?.message).toContain("secret-token"); + }); +}); diff --git a/packages/core/src/release-intelligence/operations.ts b/packages/core/src/release-intelligence/operations.ts new file mode 100644 index 0000000..21dc1a3 --- /dev/null +++ b/packages/core/src/release-intelligence/operations.ts @@ -0,0 +1,408 @@ +import { executeObservationSourceOp, scanOp } from "../operations"; +import { resolveSavedQcViews } from "../views"; +import { resolveReleaseComponentFeatureIds } from "./components"; +import type { + CompiledBehavior, + CompiledFeature, + RepositoryFacts, + RuntimeFactEvidence, +} from "./facts"; +import type { BehaviorPriority, SourceReference } from "./types"; + +export interface CompileReleaseFactsInput { + /** + * A server-owned checkout pinned to commitSha. Hosts must not pass an + * untrusted client-supplied filesystem path. + */ + readonly projectPath: string; + readonly repository: string; + readonly commitSha: string; + readonly workflowRunId: string; + readonly analyzedWorkflowPath: string; + readonly components: readonly string[]; + /** + * Least-privilege environment for observation transports. A hosted adapter + * should inject only the credential required by the selected transport. + */ + readonly env?: NodeJS.ProcessEnv; + /** Values removed recursively from persisted diagnostics. */ + readonly diagnosticSecrets?: readonly string[]; +} + +/** + * Compile release facts from an already-authorized, immutable checkout. + * Repository materialization, tenant authorization, and persistence belong to + * the embedding host. + */ +export async function compileReleaseFactsOp( + input: CompileReleaseFactsInput, +): Promise { + if (!/^[0-9a-f]{40}$/i.test(input.commitSha)) { + throw new Error("A full 40-character commit SHA is required."); + } + if (!parseRepoFullName(input.repository)) { + throw new Error("Invalid repository name."); + } + const root = input.projectPath; + const scan = await scanOp({ projectPath: root, mode: 'scan' }); + const projectFeatures = scan.result.projectMaps.primary?.map?.features ?? []; + const componentFeatureIds = resolveReleaseComponentFeatureIds( + resolveSavedQcViews(scan.result), + input.components, + projectFeatures.map((feature) => feature.id), + ); + const selectedProjectFeatures = projectFeatures.filter((feature) => + componentFeatureIds.has(feature.id), + ); + const selectedQualityMapPaths = new Set( + selectedProjectFeatures.flatMap((feature) => + feature.artifacts.qualityMapPath ? [feature.artifacts.qualityMapPath] : [], + ), + ); + const selectedQualityMaps = scan.result.qualityMaps.results.filter((result) => + selectedQualityMapPaths.has(result.source.projectRelativePath), + ); + const discoveredQualityMapPaths = new Set( + selectedQualityMaps.map((result) => result.source.projectRelativePath), + ); + const features: CompiledFeature[] = []; + for (const result of selectedQualityMaps) { + if (!result.graph) continue; + const graph = result.graph; + const mapRef: SourceReference = { + path: graph.source.projectRelativePath, + label: 'Quality map', + }; + const behaviors = graph.expectations.map((expectation): CompiledBehavior => { + const rawExpectation = result.document?.expectations?.find( + (item) => item.id === expectation.localId, + ); + const expectationRefs = (rawExpectation?.source_refs ?? []).flatMap((item) => + typeof item.path === 'string' + ? [ + { + path: item.path, + ...(typeof item.label === 'string' ? { label: item.label } : {}), + }, + ] + : [], + ); + const declarations = graph.evidence + .filter((item) => item.expectationId === expectation.normalizedId) + .map((item) => ({ + key: item.normalizedId, + kind: item.type, + sourceName: item.testCase ?? item.path ?? item.command ?? item.localId, + ...(item.path ? { path: item.path } : {}), + ...(item.url ? { url: item.url } : {}), + ...(item.command ? { command: item.command } : {}), + })); + const explicit = expectation.sourceType === 'SOURCE'; + const reviewed = graph.checksReviewed; + return { + key: expectation.normalizedId, + title: expectation.title, + ...(expectation.description ? { description: expectation.description } : {}), + priority: normalizePriority(expectation.priority), + origin: explicit + ? 'explicit' + : expectation.sourceType === 'INFERRED' + ? 'recommended' + : 'derived', + reviewStatus: reviewed ? 'confirmed' : 'proposed', + sourceRefs: expectationRefs.length > 0 ? expectationRefs : [mapRef], + requiredProof: [`${expectation.title} is satisfied at runtime`], + evidenceDeclarations: declarations, + }; + }); + features.push({ + key: graph.target.normalizedId, + name: graph.target.name, + description: `Release expectations declared by ${graph.source.projectRelativePath}`, + priority: highestPriority(behaviors.map((item) => item.priority)), + status: graph.checksReviewed ? 'confirmed' : 'proposed', + sourceRefs: [mapRef], + diagnostics: result.diagnostics.map((item) => ({ ...item })), + behaviors, + }); + } + const profiles = scan.result.observationSourceProfiles.results + .flatMap((result) => (result.status === 'parsed' ? (result.document?.profiles ?? []) : [])) + .filter((profile) => profile.transport === 'github-actions'); + const runtimeEvidence: RuntimeFactEvidence[] = []; + const runtimeDiagnostics: Record[] = []; + const eligibleProfiles = profiles.filter((profile) => { + const declaredRepository = profile.github?.repo; + if ( + typeof declaredRepository === 'string' && + observationProfileTargetsRepository(declaredRepository, input.repository) + ) { + return true; + } + runtimeDiagnostics.push({ + severity: 'warning', + code: 'CROSS_REPOSITORY_OBSERVATION_SOURCE_SKIPPED', + message: `Observation source profile ${profile.id} does not target the release repository and was skipped.`, + profileId: profile.id, + }); + return false; + }); + const selectedBehaviorKeys = new Set( + features.flatMap((feature) => feature.behaviors.map((behavior) => behavior.key)), + ); + if (eligibleProfiles.length > 0) { + const executions = await Promise.all( + eligibleProfiles.map(async (profile) => ({ + profile, + result: await executeObservationSourceOp({ + projectPath: root, + profileId: profile.id, + selection: observationSelectionForProfile({ + profileId: profile.id, + profileWorkflow: profile.github?.workflow, + analyzedWorkflowPath: input.analyzedWorkflowPath, + workflowRunId: input.workflowRunId, + commitSha: input.commitSha, + }), + env: input.env, + }), + })), + ); + for (const { profile, result } of executions) { + runtimeDiagnostics.push( + ...redactDiagnosticSecrets( + [ + ...result.execution.diagnostics.map((item) => ({ ...item, profileId: profile.id })), + ...result.resolution.diagnostics.map((item) => ({ ...item, profileId: profile.id })), + ], + input.diagnosticSecrets ?? [], + ), + ); + for (const group of result.evaluations) { + for (const target of group.targets) { + for (const expectation of target.expectations) { + if (!selectedBehaviorKeys.has(expectation.expectationId)) continue; + for (const observed of expectation.evidence) { + const resolvedObservation = observed.observationId + ? result.resolution.auditRows.find( + (item) => + item.observationId === observed.observationId && + item.evidenceId === observed.evidenceId, + ) + : undefined; + const declaration = features + .flatMap((feature) => feature.behaviors) + .flatMap((behavior) => behavior.evidenceDeclarations) + .find((item) => item.key === observed.evidenceId); + const identityStatus = + observed.commit === input.commitSha + ? ('matched' as const) + : observed.commit + ? ('mismatched' as const) + : ('unverifiable' as const); + const runtimeStatus = observationRuntime(observed.state); + const available = Boolean(observed.observationId); + runtimeEvidence.push({ + key: `observation:${profile.id}:${observed.evidenceId}:${observed.observationId ?? 'missing'}`, + evidenceKey: observed.evidenceId, + behaviorKey: expectation.expectationId, + kind: declaration?.kind ?? 'observation', + sourceName: declaration?.sourceName ?? observed.evidenceLocalId, + runtimeStatus, + identityStatus, + collectionStatus: available ? 'available' : 'missing', + ...(observed.runUrl ? { providerRef: observed.runUrl } : {}), + ...(declaration?.path ? { fileRef: { path: declaration.path } } : {}), + ...(resolvedObservation?.testFile + ? { testFile: resolvedObservation.testFile } + : {}), + ...(resolvedObservation?.testCase + ? { testCase: resolvedObservation.testCase } + : {}), + proves: + available && runtimeStatus === 'passed' && identityStatus === 'matched' + ? [`${expectation.title} is satisfied at runtime`] + : [], + reason: !available + ? 'The declared observation was not present in the selected workflow run.' + : identityStatus !== 'matched' + ? 'The observation revision does not match the release commit.' + : `The exact-run observation reported ${observed.state}.`, + }); + } + } + } + } + } + } + const repositoryDiagnostics = [ + ...scan.result.diagnostics.map((item) => ({ ...item })), + ...diagnosticsForSelectedQualityMaps(selectedQualityMaps), + ...selectedProjectFeatures + .filter((feature) => !feature.artifacts.qualityMapPath) + .map((feature) => ({ + severity: 'error', + code: 'component_feature_quality_map_missing', + message: `Component Feature ${feature.id} has no quality map.`, + })), + ...[...selectedQualityMapPaths] + .filter((path) => !discoveredQualityMapPaths.has(path)) + .map((path) => ({ + severity: 'error', + code: 'component_feature_quality_map_unavailable', + message: `Component quality map ${path} could not be scanned.`, + })), + ]; + const factIntegrity = + scan.result.status === 'completed' && + selectedProjectFeatures.every((feature) => Boolean(feature.artifacts.qualityMapPath)) && + selectedQualityMaps.length === selectedQualityMapPaths.size && + selectedQualityMaps.every(isUsableSelectedQualityMap) + ? ('complete' as const) + : ('incomplete' as const); + const integrityDiagnostics = + factIntegrity === 'incomplete' + ? repositoryDiagnostics.filter((item) => item.severity !== 'info') + : []; + if (factIntegrity === 'incomplete' && integrityDiagnostics.length === 0) { + integrityDiagnostics.push({ + severity: 'error', + code: 'REPOSITORY_FACTS_INCOMPLETE', + message: 'The repository fact compiler did not complete every selected input.', + }); + } + return { + features, + runtimeEvidence, + diagnostics: [...repositoryDiagnostics, ...runtimeDiagnostics], + integrityDiagnostics, + factIntegrity, + factSet: { + scanStatus: scan.result.status, + factIntegrity, + components: [...input.components], + artifactCount: scan.result.artifacts.length, + qualityMapCount: features.length, + observationCount: runtimeEvidence.filter((item) => item.collectionStatus === 'available') + .length, + }, + }; + +} + +export function diagnosticsForSelectedQualityMaps( + qualityMaps: readonly { + readonly diagnostics: readonly object[]; + }[], +): Record[] { + return qualityMaps.flatMap((qualityMap) => + qualityMap.diagnostics.map((diagnostic) => ({ ...diagnostic })), + ); +} + +export function isUsableSelectedQualityMap(qualityMap: { + readonly status: string; + readonly graph?: unknown; +}): boolean { + return ( + (qualityMap.status === 'valid' || qualityMap.status === 'partial') && Boolean(qualityMap.graph) + ); +} + +export function observationProfileTargetsRepository( + declaredRepository: string, + releaseRepository: string, +): boolean { + const declared = parseRepoFullName(declaredRepository); + const release = parseRepoFullName(releaseRepository); + return ( + declared !== null && + release !== null && + declared.owner.toLowerCase() === release.owner.toLowerCase() && + declared.name.toLowerCase() === release.name.toLowerCase() + ); +} + +export function observationSelectionForProfile(input: { + profileId: string; + profileWorkflow?: string; + analyzedWorkflowPath: string; + workflowRunId: string; + commitSha: string; +}) { + const selection = { commit: input.commitSha }; + if ( + !input.profileWorkflow || + workflowFileName(input.profileWorkflow) !== workflowFileName(input.analyzedWorkflowPath) + ) { + return selection; + } + return { + ...selection, + profiles: [ + { + profileId: input.profileId, + runId: Number(input.workflowRunId), + commit: input.commitSha, + }, + ], + }; +} + +function workflowFileName(value: string): string { + return value.split('@', 1)[0]?.split('/').at(-1) ?? value; +} +export function redactDiagnosticSecrets( + diagnostics: readonly Record[], + secrets: readonly string[], +): Record[] { + const presentSecrets = secrets.filter((secret) => secret.length > 0); + const redact = (value: unknown): unknown => { + if (typeof value === 'string') { + const explicit = presentSecrets.reduce( + (result, secret) => result.replaceAll(secret, '[REDACTED]'), + value, + ); + return explicit.replace( + /\b(?:gh[a-z]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, + '[REDACTED]', + ); + } + if (Array.isArray(value)) return value.map(redact); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, redact(nested)]), + ); + } + return value; + }; + return diagnostics.map((diagnostic) => redact(diagnostic) as Record); +} + +function observationRuntime( + state: 'pass' | 'fail' | 'error' | 'skipped' | 'unobserved', +): RuntimeFactEvidence['runtimeStatus'] { + if (state === 'pass') return 'passed'; + if (state === 'fail') return 'failed'; + if (state === 'error') return 'errored'; + if (state === 'skipped') return 'skipped'; + return 'unknown'; +} + +function normalizePriority(value?: string): BehaviorPriority { + return value === 'P0' || value === 'P1' || value === 'P2' || value === 'P3' ? value : 'P2'; +} + +function highestPriority(values: readonly BehaviorPriority[]): BehaviorPriority { + for (const value of ['P0', 'P1', 'P2', 'P3'] as const) if (values.includes(value)) return value; + return 'P2'; +} + + +function parseRepoFullName(value: string): { readonly owner: string; readonly name: string } | null { + const segments = value.split("/"); + if (segments.length !== 2) return null; + const [owner, name] = segments; + if (!owner || !name) return null; + return { owner, name }; +} diff --git a/packages/core/src/release-intelligence/policy.test.ts b/packages/core/src/release-intelligence/policy.test.ts new file mode 100644 index 0000000..e1f194c --- /dev/null +++ b/packages/core/src/release-intelligence/policy.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_PRODUCTION_POLICY, DEFAULT_STAGING_POLICY } from './default-rules'; +import { evaluateReleasePolicy, parseReleasePolicy } from './policy'; +import type { BehaviorAssessment } from './types'; + +function assessment(overrides: Partial = {}): BehaviorAssessment { + return { + behaviorId: 'checkout', + featureId: 'billing', + priority: 'P0', + origin: 'explicit', + reviewStatus: 'confirmed', + status: 'verified', + runtimeStatus: 'passed', + observedProof: [], + missingProof: [], + evidenceIds: [], + reason: 'fixture', + ...overrides, + }; +} + +describe('evaluateReleasePolicy', () => { + const evaluationTime = new Date('2026-09-09T00:00:00.000Z'); + + it('validates a frozen rule set before evaluation', () => { + expect(parseReleasePolicy(DEFAULT_PRODUCTION_POLICY)).toEqual(DEFAULT_PRODUCTION_POLICY); + expect(() => + parseReleasePolicy({ ...DEFAULT_PRODUCTION_POLICY, blockCheckErrors: 'yes' }), + ).toThrow(); + }); + it('is deterministic and blocks a failed required Behavior', () => { + const input = { + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [assessment({ status: 'failed', runtimeStatus: 'failed' })], + now: evaluationTime, + }; + expect(evaluateReleasePolicy(input)).toEqual(evaluateReleasePolicy(input)); + expect(evaluateReleasePolicy(input).decision).toBe('BLOCK'); + }); + + it('blocks a failed system fact without manufacturing a Behavior assessment', () => { + const result = evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [], + now: evaluationTime, + systemFacts: [ + { + key: 'repository-facts-complete', + status: 'failed', + severity: 'critical', + summary: 'Repository facts are incomplete.', + exceptionEligible: false, + findings: [], + }, + ], + }); + + expect(result.decision).toBe('BLOCK'); + expect(result.blockingAssessmentIds).toEqual([]); + expect(result.blockingSystemFactKeys).toEqual(['repository-facts-complete']); + expect(result.rules).toContainEqual( + expect.objectContaining({ + ruleKey: 'repository-facts-complete', + effect: 'block', + assessmentIds: [], + systemFactKeys: ['repository-facts-complete'], + }), + ); + }); + + it('does not turn a recommended proof gap into an undisclosed blocker', () => { + const result = evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [assessment({ origin: 'recommended', status: 'insufficient_proof' })], + now: evaluationTime, + }); + expect(result.decision).toBe('ALLOW'); + expect(result.warningAssessmentIds).toEqual(['checkout']); + }); + + it('allows an active release exception without changing the assessment', () => { + const failed = assessment({ status: 'failed', runtimeStatus: 'failed' }); + const now = new Date(); + const result = evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [failed], + exceptions: [ + { + issueId: 'i1', + behaviorId: failed.behaviorId, + reason: 'Feature remains disabled', + approvedByAccountId: 'a1', + expiresAt: new Date(now.getTime() + 60_000), + revokedAt: null, + }, + ], + now, + }); + expect(result.decision).toBe('ALLOW'); + expect(result.rules[0]?.effect).toBe('warning'); + expect(result.rules[0]?.assessmentIds).toEqual([]); + expect(result.rules[0]?.exceptedAssessmentIds).toEqual(['checkout']); + expect(result.warningAssessmentIds).toEqual(['checkout']); + expect(failed.status).toBe('failed'); + }); + + it('does not report an excepted Behavior as blocking when another Behavior still blocks', () => { + const first = assessment({ behaviorId: 'first', status: 'failed', runtimeStatus: 'failed' }); + const second = assessment({ behaviorId: 'second', status: 'failed', runtimeStatus: 'failed' }); + const now = new Date(); + const result = evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [first, second], + exceptions: [ + { + issueId: 'i1', + behaviorId: 'first', + reason: 'Scoped exception', + approvedByAccountId: 'a1', + expiresAt: new Date(now.getTime() + 60_000), + revokedAt: null, + }, + ], + now, + }); + expect(result.decision).toBe('BLOCK'); + expect(result.blockingAssessmentIds).toEqual(['second']); + expect(result.rules[0]?.assessmentIds).toEqual(['second']); + expect(result.rules[0]?.exceptedAssessmentIds).toEqual(['first']); + }); + + it('blocks required explicit proof gaps according to environment priority', () => { + const gap = assessment({ + priority: 'P1', + status: 'insufficient_proof', + runtimeStatus: 'unknown', + }); + + expect( + evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [gap], + now: evaluationTime, + }).decision, + ).toBe('BLOCK'); + expect( + evaluateReleasePolicy({ + policy: DEFAULT_STAGING_POLICY, + assessments: [gap], + now: evaluationTime, + }).decision, + ).toBe('ALLOW'); + }); + + it.each(['check_error', 'conflicting_facts'] as const)( + 'blocks a %s assessment when the policy enables that gate', + (status) => { + const result = evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [assessment({ status, runtimeStatus: 'errored' })], + now: evaluationTime, + }); + + expect(result.decision).toBe('BLOCK'); + }, + ); + + it('does not apply an expired or revoked exception', () => { + const now = new Date(); + const failed = assessment({ status: 'failed', runtimeStatus: 'failed' }); + const baseException = { + issueId: 'i1', + behaviorId: failed.behaviorId, + reason: 'Scoped exception', + approvedByAccountId: 'a1', + }; + + for (const exception of [ + { ...baseException, expiresAt: new Date(now.getTime() - 1), revokedAt: null }, + { + ...baseException, + expiresAt: new Date(now.getTime() + 60_000), + revokedAt: new Date(now.getTime() - 1), + }, + ]) { + expect( + evaluateReleasePolicy({ + policy: DEFAULT_PRODUCTION_POLICY, + assessments: [failed], + exceptions: [exception], + now, + }).decision, + ).toBe('BLOCK'); + } + }); +}); diff --git a/packages/core/src/release-intelligence/policy.ts b/packages/core/src/release-intelligence/policy.ts new file mode 100644 index 0000000..788d7b9 --- /dev/null +++ b/packages/core/src/release-intelligence/policy.ts @@ -0,0 +1,150 @@ +import type { + BehaviorAssessment, + ReleaseException, + ReleasePolicyDecision, + ReleaseRuleResult, + ReleaseSystemFact, +} from './types'; +import { z } from 'zod'; + +export interface ReleasePolicy { + readonly environment: 'staging' | 'production'; + readonly blockFailedPriorities: readonly BehaviorAssessment['priority'][]; + readonly blockInsufficientExplicitPriorities: readonly BehaviorAssessment['priority'][]; + readonly blockCheckErrors: boolean; + readonly blockConflicts: boolean; +} +const ReleasePolicySchema = z + .object({ + environment: z.enum(['staging', 'production']), + blockFailedPriorities: z.array(z.enum(['P0', 'P1', 'P2', 'P3'])), + blockInsufficientExplicitPriorities: z.array(z.enum(['P0', 'P1', 'P2', 'P3'])), + blockCheckErrors: z.boolean(), + blockConflicts: z.boolean(), + }) + .strict(); + +export function parseReleasePolicy(value: unknown): ReleasePolicy { + return ReleasePolicySchema.parse(value); +} + +export function evaluateReleasePolicy(input: { + readonly policy: ReleasePolicy; + readonly assessments: readonly BehaviorAssessment[]; + readonly systemFacts?: readonly ReleaseSystemFact[]; + readonly exceptions?: readonly ReleaseException[]; + /** Evaluation time is an explicit fact so identical inputs always produce identical decisions. */ + readonly now: Date; +}): ReleasePolicyDecision { + const excepted = new Set( + (input.exceptions ?? []) + .filter( + (item) => item.revokedAt === null && item.expiresAt.getTime() > input.now.getTime(), + ) + .map((item) => item.behaviorId), + ); + const active = input.assessments.filter( + (item) => item.status !== 'not_applicable' && item.reviewStatus !== 'rejected', + ); + + const failed = active.filter( + (item) => + item.status === 'failed' && input.policy.blockFailedPriorities.includes(item.priority), + ); + const insufficient = active.filter( + (item) => + item.status === 'insufficient_proof' && + item.origin === 'explicit' && + item.reviewStatus === 'confirmed' && + input.policy.blockInsufficientExplicitPriorities.includes(item.priority), + ); + const errors = input.policy.blockCheckErrors + ? active.filter((item) => item.status === 'check_error') + : []; + const conflicts = input.policy.blockConflicts + ? active.filter((item) => item.status === 'conflicting_facts') + : []; + + const behaviorRules: ReleaseRuleResult[] = [ + rule('required-behavior-failed', failed, 'Required Behavior failures'), + rule('required-proof-missing', insufficient, 'Required proof gaps'), + rule('assessment-system-error', errors, 'Assessment system errors'), + rule('conflicting-facts', conflicts, 'Conflicting product facts'), + ].map((item) => applyExceptions(item, excepted)); + const systemRules: ReleaseRuleResult[] = (input.systemFacts ?? []).map((fact) => ({ + ruleKey: fact.key, + status: fact.status === 'passed' ? 'pass' : fact.severity === 'critical' ? 'fail' : 'warn', + effect: fact.status === 'passed' ? 'none' : fact.severity === 'critical' ? 'block' : 'warning', + assessmentIds: [], + exceptedAssessmentIds: [], + systemFactKeys: [fact.key], + reason: fact.summary, + })); + const rules = [...behaviorRules, ...systemRules]; + + const blockingAssessmentIds = unique( + rules.filter((item) => item.effect === 'block').flatMap((item) => item.assessmentIds), + ); + const blockingSystemFactKeys = unique( + rules.filter((item) => item.effect === 'block').flatMap((item) => item.systemFactKeys), + ); + const explicitlyRuled = new Set( + rules.flatMap((item) => [...item.assessmentIds, ...item.exceptedAssessmentIds]), + ); + const warningAssessmentIds = unique([ + ...rules.filter((item) => item.effect === 'warning').flatMap((item) => item.assessmentIds), + ...rules.flatMap((item) => item.exceptedAssessmentIds), + ...active + .filter((item) => !explicitlyRuled.has(item.behaviorId) && item.status !== 'verified') + .map((item) => item.behaviorId), + ]); + + return { + decision: rules.some((item) => item.effect === 'block') ? 'BLOCK' : 'ALLOW', + rules, + blockingAssessmentIds, + blockingSystemFactKeys, + warningAssessmentIds, + }; +} + +function rule( + ruleKey: string, + assessments: readonly BehaviorAssessment[], + label: string, +): ReleaseRuleResult { + const ids = assessments.map((item) => item.behaviorId); + return { + ruleKey, + status: ids.length === 0 ? 'pass' : 'fail', + effect: ids.length === 0 ? 'none' : 'block', + assessmentIds: ids, + exceptedAssessmentIds: [], + systemFactKeys: [], + reason: ids.length === 0 ? `${label}: none found.` : `${label}: ${ids.length} found.`, + }; +} + +function applyExceptions( + result: ReleaseRuleResult, + exceptedBehaviorIds: ReadonlySet, +): ReleaseRuleResult { + if (result.effect !== 'block') return result; + const remaining = result.assessmentIds.filter((id) => !exceptedBehaviorIds.has(id)); + const accepted = result.assessmentIds.filter((id) => exceptedBehaviorIds.has(id)); + if (accepted.length === 0) return result; + // Keep waived inputs separate from unresolved inputs so both the decision + // and its exception trail remain reconstructable from the stored result. + return { + ...result, + status: remaining.length === 0 ? 'warn' : 'fail', + effect: remaining.length === 0 ? 'warning' : 'block', + assessmentIds: remaining, + exceptedAssessmentIds: accepted, + reason: `${result.reason} ${accepted.length} covered by active release exception.`, + }; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/core/src/release-intelligence/read-models.ts b/packages/core/src/release-intelligence/read-models.ts new file mode 100644 index 0000000..249ea16 --- /dev/null +++ b/packages/core/src/release-intelligence/read-models.ts @@ -0,0 +1,214 @@ +import type { + AnalysisStatus, + BehaviorAssessmentStatus, + BehaviorOrigin, + BehaviorPriority, + BehaviorReviewStatus, + ReleaseEnvironment, + ReleasePublishStatus, + ReleaseRecordSource, + RuntimeStatus, + SourceReference, +} from "./types"; + +export interface ReleaseListAttemptView { + readonly status: AnalysisStatus; + readonly decision: "ALLOW" | "BLOCK" | null; + readonly attemptNumber: number; +} + +/** + * Serializable list projection consumed by shared release presentation. + * Hosts map persistence records into this shape at their server boundary. + */ +export interface ReleaseListItemView { + readonly id: string; + readonly repository: string; + readonly workflowName: string; + readonly source: ReleaseRecordSource; + readonly workflowRunId: string; + readonly workflowUrl: string; + readonly commitSha: string; + readonly environment: ReleaseEnvironment; + readonly components: readonly string[]; + readonly publishStatus: ReleasePublishStatus; + readonly createdAt: string; + readonly latestAttempt: ReleaseListAttemptView | null; +} + +export interface ReleaseListView { + readonly records: readonly ReleaseListItemView[]; + readonly nextCursor: string | null; +} + +export interface ReleaseAttemptView { + readonly id: string; + readonly attemptNumber: number; + readonly triggerType: "workflow_gate" | "historical_import" | "manual_reanalysis"; + readonly status: AnalysisStatus; + readonly analyzerVersion: string; + readonly factSetHash: string | null; + readonly decision: "ALLOW" | "BLOCK" | null; + readonly summary: unknown; + readonly diagnostics: readonly Record[]; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly createdAt: string; +} + +export interface ReleaseSystemFactView { + readonly id: string; + readonly factKey: string; + readonly status: "passed" | "failed"; + readonly severity: "warning" | "critical"; + readonly summary: string; + readonly exceptionEligible: boolean; + /** Persisted JSON is validated before presentation. */ + readonly findings: unknown; +} + +export interface ReleaseAnalysisHealthView { + readonly systemFacts: readonly ReleaseSystemFactView[]; +} + +export interface ReleaseAnalysisHistoryView { + readonly attempts: readonly ReleaseAttemptView[]; +} + +export interface ReleaseEvidenceRecordView { + readonly id: string; + readonly evidenceKey: string; + readonly kind: string; + readonly sourceName: string; + readonly runtimeStatus: RuntimeStatus; + readonly identityStatus: "matched" | "mismatched" | "unverifiable"; + readonly collectionStatus: "available" | "expired" | "missing" | "parse_error"; + readonly fileRef: SourceReference | null; + readonly providerRef: string | null; + readonly archiveRef: string | null; + readonly contentHash: string | null; + readonly details: Readonly>; +} + +export interface ReleaseEvidenceInputsView extends ReleaseAnalysisHealthView { + readonly repository: string; + readonly release: { + readonly commitSha: string; + }; + readonly evidence: readonly ReleaseEvidenceRecordView[]; +} + +export interface ReleaseFeatureSnapshotView { + readonly id: string; + readonly featureKey: string; + readonly name: string; + readonly description: string; + readonly priority: BehaviorPriority; + readonly status: string; + readonly sourceRefs: readonly SourceReference[]; + readonly diagnostics: readonly Record[]; +} + +export interface ReleaseBehaviorSnapshotView { + readonly id: string; + readonly behaviorKey: string; + readonly title: string; + readonly description: string | null; + readonly priority: BehaviorPriority; + readonly origin: BehaviorOrigin; + readonly reviewStatus: BehaviorReviewStatus; + readonly sourceRefs: readonly SourceReference[]; + readonly requiredProof: readonly string[]; + readonly applicable: boolean; +} + +export interface ReleaseBehaviorItemView { + readonly featureId: string; + readonly behavior: ReleaseBehaviorSnapshotView; +} + +export interface ReleaseBehaviorAssessmentView { + readonly id: string; + readonly behaviorSnapshotId: string; + readonly status: BehaviorAssessmentStatus; + readonly runtimeStatus: RuntimeStatus; + readonly observedProof: readonly string[]; + readonly missingProof: readonly string[]; + readonly reason: string; +} + +export interface ReleaseIssueView { + readonly id: string; + readonly assessmentId: string | null; + readonly systemFactId: string | null; + readonly kind: string; + readonly severity: string; + readonly title: string; + readonly reason: string; + readonly recommendedAction: string; + readonly blocksWithoutException: boolean; +} + +export interface ReleaseAssessmentEvidenceView { + readonly assessmentId: string; + readonly evidenceRecordId: string; + readonly relationship: "direct" | "partial" | "indirect" | "conflicting"; + readonly reason: string; +} + +export interface ReleaseFeatureBrowserView { + readonly repository: string; + readonly release: { + readonly commitSha: string; + }; + readonly features: readonly ReleaseFeatureSnapshotView[]; + readonly behaviors: readonly ReleaseBehaviorItemView[]; + readonly assessments: readonly ReleaseBehaviorAssessmentView[]; + readonly issues: readonly ReleaseIssueView[]; + readonly assessmentEvidence: readonly ReleaseAssessmentEvidenceView[]; +} + +export interface ReleaseRuleResultView { + readonly id: string; + readonly ruleKey: string; + readonly status: "pass" | "warn" | "fail" | "not_applicable"; + readonly inputs: Readonly>; + readonly effect: "none" | "warning" | "block"; + readonly reason: string; +} + +export interface ReleaseIssueEvidenceView + extends ReleaseFeatureBrowserView, + ReleaseAnalysisHealthView { + readonly evidence: readonly ReleaseEvidenceRecordView[]; + readonly rules: readonly ReleaseRuleResultView[]; +} + +export interface ReleaseExceptionView { + readonly id: string; + readonly issueId: string; + readonly reason: string; + readonly expiresAt: string; + readonly revokedAt: string | null; + readonly isActive: boolean; +} + +/** + * Serializable detail projection consumed by the complete shared release UI. + * Authentication, persistence and exception authorization stay in the host. + */ +export interface ReleaseDetailView + extends ReleaseIssueEvidenceView, + ReleaseAnalysisHistoryView { + readonly release: { + readonly id: string; + readonly commitSha: string; + readonly workflowName: string; + readonly workflowUrl: string; + readonly workflowRunId: string; + readonly environment: ReleaseEnvironment; + readonly components: readonly string[]; + readonly publishStatus: ReleasePublishStatus; + }; + readonly exceptions: readonly ReleaseExceptionView[]; +} diff --git a/packages/core/src/release-intelligence/system-facts.test.ts b/packages/core/src/release-intelligence/system-facts.test.ts new file mode 100644 index 0000000..177acb3 --- /dev/null +++ b/packages/core/src/release-intelligence/system-facts.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { + buildReleaseSystemFacts, + parseReleaseSystemFactFindings, + systemFactFindingPresentation, +} from './system-facts'; + +describe('parseReleaseSystemFactFindings', () => { + it('accepts persisted findings and rejects malformed JSON as a whole', () => { + const finding = { + code: 'INVALID_YAML', + message: 'Could not parse quality-map.yaml.', + remediation: 'Fix the YAML syntax.', + line: 3, + }; + + expect(parseReleaseSystemFactFindings([finding])).toEqual([finding]); + expect(parseReleaseSystemFactFindings([{ ...finding, line: 0 }])).toEqual([]); + expect(parseReleaseSystemFactFindings(null)).toEqual([]); + }); +}); + +describe('buildReleaseSystemFacts', () => { + it('turns a missing evidence file into an actionable repository integrity finding', () => { + const facts = buildReleaseSystemFacts({ + factIntegrity: 'incomplete', + evidenceTruncated: false, + integrityDiagnostics: [ + { + severity: 'warning', + code: 'MISSING_EVIDENCE_FILE', + message: + 'Evidence path apps/cli/tests/integration/standalone-export.test.ts referenced by ev-standalone-export-logic does not exist in the scanned repo.', + affectedPath: '.quality/evidence/002-shiplightai-cli/quality-map.yaml', + }, + ], + features: [ + { + key: 'feature:002-shiplightai-cli', + name: 'shiplightai CLI & Playwright Library', + sourceRefs: [ + { + path: '.quality/evidence/002-shiplightai-cli/quality-map.yaml', + label: 'Quality map', + }, + ], + behaviors: [ + { + key: 'behavior:exp-transpile', + title: 'YAML tests transpile to correct Playwright spec files', + evidenceDeclarations: [ + { + key: 'evidence:ev-standalone-export-logic', + path: 'apps/cli/tests/integration/standalone-export.test.ts', + }, + ], + }, + ], + }, + ], + }); + + expect(facts).toEqual([ + { + key: 'repository-facts-complete', + status: 'failed', + severity: 'critical', + summary: '1 repository fact problem prevented a complete scan.', + exceptionEligible: false, + findings: [ + expect.objectContaining({ + code: 'MISSING_EVIDENCE_FILE', + featureKey: 'feature:002-shiplightai-cli', + featureName: 'shiplightai CLI & Playwright Library', + behaviorKey: 'behavior:exp-transpile', + behaviorTitle: 'YAML tests transpile to correct Playwright spec files', + evidenceKey: 'ev-standalone-export-logic', + declarationPath: '.quality/evidence/002-shiplightai-cli/quality-map.yaml', + affectedPath: 'apps/cli/tests/integration/standalone-export.test.ts', + remediation: + 'Update the evidence declaration to reference an existing repository file, restore the missing file, or remove the declaration if it no longer applies. Then re-run the analysis.', + }), + ], + }, + { + key: 'workflow-evidence-complete', + status: 'passed', + severity: 'critical', + summary: 'Workflow evidence collection stayed within the supported bounds.', + exceptionEligible: false, + findings: [], + }, + ]); + }); + + it('reports truncated workflow inputs as a separate blocking system fact', () => { + const facts = buildReleaseSystemFacts({ + factIntegrity: 'complete', + evidenceTruncated: true, + integrityDiagnostics: [], + features: [], + }); + + expect(facts).toEqual([ + expect.objectContaining({ + key: 'repository-facts-complete', + status: 'passed', + findings: [], + }), + expect.objectContaining({ + key: 'workflow-evidence-complete', + status: 'failed', + severity: 'critical', + exceptionEligible: false, + findings: [ + expect.objectContaining({ + code: 'WORKFLOW_EVIDENCE_TRUNCATED', + remediation: expect.stringContaining('collection bound'), + }), + ], + }), + ]); + }); +}); + +describe('systemFactFindingPresentation', () => { + it('turns a missing evidence diagnostic into user-facing guidance', () => { + expect( + systemFactFindingPresentation({ + code: 'MISSING_EVIDENCE_FILE', + message: + 'Evidence path apps/cli/test.ts referenced by ev-cli does not exist in the scanned repo.', + remediation: 'Restore, replace, or remove the stale evidence declaration.', + declarationPath: '.quality/evidence/cli/quality-map.yaml', + affectedPath: 'apps/cli/test.ts', + }), + ).toEqual({ + title: 'Configured evidence file is missing', + explanation: + 'This evidence declaration points to a file that does not exist at the analyzed commit.', + recommendedAction: + 'Update the evidence declaration to reference an existing repository file, restore the missing file, or remove the declaration if it no longer applies. Then re-run the analysis.', + }); + }); + + it('uses deterministic quality-core guidance for other scanner diagnostics', () => { + expect( + systemFactFindingPresentation({ + code: 'INVALID_YAML', + message: 'Could not parse quality-map.yaml.', + remediation: 'Open the declaration and correct the reported repository fact.', + declarationPath: '.quality/evidence/api/quality-map.yaml', + }), + ).toMatchObject({ + title: 'Invalid YAML', + recommendedAction: 'Open the referenced YAML file, fix the syntax error, and scan again.', + }); + }); + + it('retains a quality-map diagnostic location as an exact-commit declaration', () => { + const [repositoryFact] = buildReleaseSystemFacts({ + factIntegrity: 'incomplete', + evidenceTruncated: false, + integrityDiagnostics: [ + { + severity: 'warning', + code: 'UNKNOWN_FIELD', + message: "Unknown quality-map field 'risk' is ignored.", + mapPath: '.quality/evidence/web/quality-map.yaml', + yamlPath: '$.expectations[0].risk', + line: 32, + column: 5, + snippet: 'risk:', + }, + ], + features: [], + }); + + expect(repositoryFact?.findings).toEqual([ + expect.objectContaining({ + code: 'UNKNOWN_FIELD', + declarationPath: '.quality/evidence/web/quality-map.yaml', + yamlPath: '$.expectations[0].risk', + line: 32, + column: 5, + snippet: 'risk:', + }), + ]); + }); +}); diff --git a/packages/core/src/release-intelligence/system-facts.ts b/packages/core/src/release-intelligence/system-facts.ts new file mode 100644 index 0000000..963252d --- /dev/null +++ b/packages/core/src/release-intelligence/system-facts.ts @@ -0,0 +1,221 @@ +import { diagnosticGuidanceFor } from '../project-index/diagnostic-guidance'; +import { z } from 'zod'; +import type { ReleaseSystemFact, ReleaseSystemFactFinding, SourceReference } from './types'; + +const ReleaseSystemFactFindingSchema = z.object({ + code: z.string(), + message: z.string(), + remediation: z.string(), + featureKey: z.string().optional(), + featureName: z.string().optional(), + behaviorKey: z.string().optional(), + behaviorTitle: z.string().optional(), + evidenceKey: z.string().optional(), + declarationPath: z.string().optional(), + affectedPath: z.string().optional(), + yamlPath: z.string().optional(), + line: z.number().int().positive().optional(), + column: z.number().int().positive().optional(), + snippet: z.string().optional(), +}); + +export function parseReleaseSystemFactFindings( + value: unknown, +): readonly ReleaseSystemFactFinding[] { + const parsed = z.array(ReleaseSystemFactFindingSchema).safeParse(value); + return parsed.success ? parsed.data : []; +} + +export interface SystemFactFindingPresentation { + readonly title: string; + readonly explanation: string; + readonly recommendedAction: string; +} + +interface SystemFactFeature { + readonly key: string; + readonly name: string; + readonly sourceRefs: readonly SourceReference[]; + readonly behaviors: readonly { + readonly key: string; + readonly title: string; + readonly evidenceDeclarations: readonly { + readonly key: string; + readonly path?: string; + }[]; + }[]; +} + +// Pure transformation: callers own persistence so identical frozen inputs remain reproducible. +export function buildReleaseSystemFacts(input: { + readonly factIntegrity: 'complete' | 'incomplete'; + readonly evidenceTruncated: boolean; + readonly integrityDiagnostics: readonly Record[]; + readonly features: readonly SystemFactFeature[]; +}): ReleaseSystemFact[] { + const repositoryFindings = + input.factIntegrity === 'incomplete' + ? input.integrityDiagnostics.map((diagnostic) => + repositoryFinding(diagnostic, input.features), + ) + : []; + const repositoryProblemCount = repositoryFindings.length; + const repositoryFact: ReleaseSystemFact = { + key: 'repository-facts-complete', + status: input.factIntegrity === 'complete' ? 'passed' : 'failed', + severity: 'critical', + summary: + input.factIntegrity === 'complete' + ? 'Repository facts compiled completely.' + : `${repositoryProblemCount} repository fact ${repositoryProblemCount === 1 ? 'problem' : 'problems'} prevented a complete scan.`, + exceptionEligible: false, + findings: repositoryFindings, + }; + const workflowFact: ReleaseSystemFact = { + key: 'workflow-evidence-complete', + status: input.evidenceTruncated ? 'failed' : 'passed', + severity: 'critical', + summary: input.evidenceTruncated + ? 'GitHub returned more workflow evidence inputs than the collector can safely process.' + : 'Workflow evidence collection stayed within the supported bounds.', + exceptionEligible: false, + findings: input.evidenceTruncated + ? [ + { + code: 'WORKFLOW_EVIDENCE_TRUNCATED', + message: + 'GitHub returned more workflow evidence inputs than the bounded collector retained.', + remediation: + 'Reduce the workflow evidence set or increase the supported collection bound before re-analysis.', + }, + ] + : [], + }; + return [repositoryFact, workflowFact]; +} + +function repositoryFinding( + diagnostic: Record, + features: readonly SystemFactFeature[], +): ReleaseSystemFactFinding { + const code = typeof diagnostic.code === 'string' ? diagnostic.code : 'REPOSITORY_FACT_ERROR'; + const message = + typeof diagnostic.message === 'string' + ? diagnostic.message + : 'A repository fact could not be compiled.'; + const declaredAt = + typeof diagnostic.affectedPath === 'string' + ? diagnostic.affectedPath + : typeof diagnostic.mapPath === 'string' + ? diagnostic.mapPath + : undefined; + const yamlPath = typeof diagnostic.yamlPath === 'string' ? diagnostic.yamlPath : undefined; + const line = positiveInteger(diagnostic.line); + const column = positiveInteger(diagnostic.column); + const snippet = typeof diagnostic.snippet === 'string' ? diagnostic.snippet : undefined; + const missingEvidence = + // quality-core currently exposes the evidence key and missing path only in this message. + // Its emitted format is locked by scan-project.contract.test.ts and this parser by + // system-facts.test.ts; update both contracts when upgrading that diagnostic. + code === 'MISSING_EVIDENCE_FILE' + ? /^Evidence path (.+) referenced by (.+) does not exist in the scanned repo\.$/.exec(message) + : null; + const affectedPath = missingEvidence?.[1]; + const evidenceKey = missingEvidence?.[2]; + const feature = features.find((item) => + item.sourceRefs.some((reference) => reference.path === declaredAt), + ); + const behavior = feature?.behaviors.find((item) => + item.evidenceDeclarations.some( + (evidence) => + evidence.path === affectedPath || + (evidenceKey !== undefined && evidence.key.endsWith(`:${evidenceKey}`)), + ), + ); + return { + code, + message, + remediation: + code === 'MISSING_EVIDENCE_FILE' + ? 'Update the evidence declaration to reference an existing repository file, restore the missing file, or remove the declaration if it no longer applies. Then re-run the analysis.' + : 'Open the declaration and correct the reported repository fact before re-analysis.', + ...(feature ? { featureKey: feature.key, featureName: feature.name } : {}), + ...(behavior ? { behaviorKey: behavior.key, behaviorTitle: behavior.title } : {}), + ...(evidenceKey ? { evidenceKey } : {}), + ...(declaredAt ? { declarationPath: declaredAt } : {}), + ...(affectedPath ? { affectedPath } : {}), + ...(yamlPath ? { yamlPath } : {}), + ...(line ? { line } : {}), + ...(column ? { column } : {}), + ...(snippet ? { snippet } : {}), + }; +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} + +export function systemFactFindingPresentation( + finding: ReleaseSystemFactFinding, +): SystemFactFindingPresentation { + if (finding.code === 'MISSING_EVIDENCE_FILE') { + return { + title: 'Configured evidence file is missing', + explanation: + 'This evidence declaration points to a file that does not exist at the analyzed commit.', + recommendedAction: + 'Update the evidence declaration to reference an existing repository file, restore the missing file, or remove the declaration if it no longer applies. Then re-run the analysis.', + }; + } + if (finding.code === 'component_feature_quality_map_missing') { + return { + title: 'Feature has no quality map', + explanation: 'A selected Feature does not declare the quality map needed for analysis.', + recommendedAction: + 'Add a quality map for the Feature or update the project configuration to reference the correct map, then re-run the analysis.', + }; + } + if (finding.code === 'component_feature_quality_map_unavailable') { + return { + title: 'Quality map could not be scanned', + explanation: + 'A selected Feature references a quality map that was not available in the analyzed commit.', + recommendedAction: + 'Restore the referenced quality map or correct its project configuration path, then re-run the analysis.', + }; + } + if (finding.code === 'REPOSITORY_FACTS_INCOMPLETE') { + return { + title: 'Repository configuration scan did not complete', + explanation: finding.message, + recommendedAction: + 'Review the other repository configuration problems, correct the affected inputs, and re-run the analysis.', + }; + } + if (finding.code === 'WORKFLOW_EVIDENCE_TRUNCATED') { + return { + title: 'Workflow evidence exceeded the collection limit', + explanation: + 'The workflow returned more evidence inputs than Shiplight could safely retain for this analysis.', + recommendedAction: finding.remediation, + }; + } + + const guidance = diagnosticGuidanceFor({ + severity: 'error', + code: finding.code, + message: finding.message, + ...(finding.declarationPath ? { sourcePath: finding.declarationPath } : {}), + ...(finding.affectedPath ? { affectedPath: finding.affectedPath } : {}), + ...(finding.featureKey ? { affectedTargetId: finding.featureKey } : {}), + }); + return { + title: sentenceCase(guidance.title), + explanation: guidance.explanation, + recommendedAction: guidance.recommendedAction, + }; +} + +function sentenceCase(value: string): string { + return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} diff --git a/packages/core/src/release-intelligence/types.ts b/packages/core/src/release-intelligence/types.ts new file mode 100644 index 0000000..0fc5f97 --- /dev/null +++ b/packages/core/src/release-intelligence/types.ts @@ -0,0 +1,138 @@ +export const RELEASE_ENVIRONMENTS = ['staging', 'production'] as const; +export type ReleaseEnvironment = (typeof RELEASE_ENVIRONMENTS)[number]; + +export const RELEASE_RECORD_SOURCES = ['workflow_gate', 'historical_import'] as const; +export type ReleaseRecordSource = (typeof RELEASE_RECORD_SOURCES)[number]; + +export const RELEASE_PUBLISH_STATUSES = [ + 'unknown', + 'publishing', + 'published', + 'failed', + 'cancelled', +] as const; +export type ReleasePublishStatus = (typeof RELEASE_PUBLISH_STATUSES)[number]; + +export const ANALYSIS_STATUSES = [ + 'queued', + 'resolving', + 'collecting', + 'assessing', + 'evaluating', + 'allow', + 'block', + 'inconclusive', + 'error', +] as const; +export type AnalysisStatus = (typeof ANALYSIS_STATUSES)[number]; + +export const BEHAVIOR_ASSESSMENT_STATUSES = [ + 'verified', + 'failed', + 'insufficient_proof', + 'conflicting_facts', + 'check_error', + 'not_applicable', +] as const; +export type BehaviorAssessmentStatus = (typeof BEHAVIOR_ASSESSMENT_STATUSES)[number]; + +export type BehaviorOrigin = 'explicit' | 'derived' | 'recommended'; +export type BehaviorReviewStatus = 'confirmed' | 'proposed' | 'rejected'; +export type BehaviorPriority = 'P0' | 'P1' | 'P2' | 'P3'; +export type RuntimeStatus = 'passed' | 'failed' | 'skipped' | 'errored' | 'unknown'; + +export interface SourceReference { + readonly path: string; + readonly startLine?: number; + readonly endLine?: number; + readonly label?: string; +} +export interface ExpectedBehavior { + readonly id: string; + readonly featureId: string; + readonly title: string; + readonly description?: string; + readonly priority: BehaviorPriority; + readonly origin: BehaviorOrigin; + readonly reviewStatus: BehaviorReviewStatus; + readonly sourceRefs: readonly SourceReference[]; + readonly requiredProof: readonly string[]; + readonly applicable: boolean; +} + +export interface EvidenceContribution { + readonly id: string; + readonly runtimeStatus: RuntimeStatus; + readonly identityStatus: 'matched' | 'mismatched' | 'unverifiable'; + readonly collectionStatus: 'available' | 'expired' | 'missing' | 'parse_error'; + readonly relationship: 'direct' | 'partial' | 'indirect' | 'conflicting'; + readonly proves: readonly string[]; + readonly reason: string; +} + +export interface BehaviorAssessment { + readonly behaviorId: string; + readonly featureId: string; + readonly priority: BehaviorPriority; + readonly origin: BehaviorOrigin; + readonly reviewStatus: BehaviorReviewStatus; + readonly status: BehaviorAssessmentStatus; + readonly runtimeStatus: RuntimeStatus; + readonly observedProof: readonly string[]; + readonly missingProof: readonly string[]; + readonly evidenceIds: readonly string[]; + readonly reason: string; +} + +export interface ReleaseRuleResult { + readonly ruleKey: string; + readonly status: 'pass' | 'warn' | 'fail' | 'not_applicable'; + readonly effect: 'none' | 'warning' | 'block'; + readonly assessmentIds: readonly string[]; + readonly exceptedAssessmentIds: readonly string[]; + readonly systemFactKeys: readonly string[]; + readonly reason: string; +} + +export interface ReleasePolicyDecision { + readonly decision: 'ALLOW' | 'BLOCK'; + readonly rules: readonly ReleaseRuleResult[]; + readonly blockingAssessmentIds: readonly string[]; + readonly blockingSystemFactKeys: readonly string[]; + readonly warningAssessmentIds: readonly string[]; +} + +export interface ReleaseSystemFactFinding { + readonly code: string; + readonly message: string; + readonly remediation: string; + readonly featureKey?: string; + readonly featureName?: string; + readonly behaviorKey?: string; + readonly behaviorTitle?: string; + readonly evidenceKey?: string; + readonly declarationPath?: string; + readonly affectedPath?: string; + readonly yamlPath?: string; + readonly line?: number; + readonly column?: number; + readonly snippet?: string; +} + +export interface ReleaseSystemFact { + readonly key: string; + readonly status: 'passed' | 'failed'; + readonly severity: 'warning' | 'critical'; + readonly summary: string; + readonly exceptionEligible: boolean; + readonly findings: readonly ReleaseSystemFactFinding[]; +} + +export interface ReleaseException { + readonly issueId: string; + readonly behaviorId: string; + readonly reason: string; + readonly approvedByAccountId: string; + readonly expiresAt: Date; + readonly revokedAt: Date | null; +} diff --git a/packages/core/src/release-intelligence/workflow-reference.test.ts b/packages/core/src/release-intelligence/workflow-reference.test.ts new file mode 100644 index 0000000..a539c45 --- /dev/null +++ b/packages/core/src/release-intelligence/workflow-reference.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { parseWorkflowReference } from './workflow-reference'; + +describe('parseWorkflowReference', () => { + it('accepts a workflow run id', () => { + expect(parseWorkflowReference('12345')).toEqual({ runId: '12345' }); + }); + + it('accepts a GitHub workflow run URL pinned to an attempt', () => { + expect(parseWorkflowReference('https://github.com/ShiplightAI/shipyard/actions/runs/123/attempts/2')).toEqual({ + owner: 'ShiplightAI', repo: 'shipyard', runId: '123', runAttempt: 2, + }); + }); + + it('rejects non-run URLs', () => { + expect(parseWorkflowReference('https://github.com/a/b/actions')).toBeNull(); + }); +}); diff --git a/packages/core/src/release-intelligence/workflow-reference.ts b/packages/core/src/release-intelligence/workflow-reference.ts new file mode 100644 index 0000000..aeeb797 --- /dev/null +++ b/packages/core/src/release-intelligence/workflow-reference.ts @@ -0,0 +1,20 @@ +const RUN_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(?:\/attempts\/(\d+))?(?:[/?#].*)?$/i; + +export interface WorkflowReference { + readonly owner?: string; + readonly repo?: string; + readonly runId: string; + readonly runAttempt?: number; +} +export function parseWorkflowReference(value: string): WorkflowReference | null { + const input = value.trim(); + if (/^\d+$/.test(input)) return { runId: input }; + const match = RUN_URL.exec(input); + if (!match) return null; + return { + owner: match[1], + repo: match[2], + runId: match[3]!, + ...(match[4] ? { runAttempt: Number(match[4]) } : {}), + }; +} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index ac49df9..255ffd8 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ observations: "src/observations/index.ts", operations: "src/operations/index.ts", "recommendation-export": "src/recommendation-export/index.ts", + "release-intelligence": "src/release-intelligence/index.ts", + "release-intelligence-operations": "src/release-intelligence/operations.ts", "owner-view": "src/owner-view/index.ts", "project-map": "src/project-map/index.ts", "project-index": "src/project-index/index.ts", diff --git a/packages/ui/README.md b/packages/ui/README.md index 7f367b7..2b2c373 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -55,12 +55,53 @@ return + + + + +; +``` + +The route and API prefixes are host-owned so Quality Explorer and Shiplight can +mount the same UI under different paths. Rendering outside the provider fails +fast, matching the Quality Center integration guard. Exception controls are +disabled unless the host explicitly supplies human approval authority through +`canApproveExceptions`; the shared UI never decides or manufactures approval. + ## Packaging -Published as **TypeScript source**, not a bundle: 19 of the 26 components carry -`"use client"`, and a bundled build would have to re-emit those directives per -chunk. Consumers add it to `transpilePackages` and compile it like first-party -code: +Published as bundled ESM with dedicated JavaScript, declarations, and stylesheet +entries. The build restores `"use client"` on each public React entry after +bundling. Workspace consumers may still add it to `transpilePackages` while +developing against the package: ```ts // next.config.ts diff --git a/packages/ui/package-size.json b/packages/ui/package-size.json index 43539b4..958ef0c 100644 --- a/packages/ui/package-size.json +++ b/packages/ui/package-size.json @@ -1,6 +1,6 @@ { - "baselineVersion": "0.1.0", - "baselinePackedBytes": 39089, - "baselineUnpackedBytes": 166479, + "baselineVersion": "0.1.1", + "baselinePackedBytes": 51954, + "baselineUnpackedBytes": 219221, "maxIncreasePercent": 1 } diff --git a/packages/ui/package.json b/packages/ui/package.json index 931ddd4..98b600a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,6 +27,12 @@ "import": "./dist/helpers.js", "default": "./dist/helpers.js" }, + "./release-intelligence": { + "types": "./dist/release-intelligence.d.ts", + "import": "./dist/release-intelligence.js", + "default": "./dist/release-intelligence.js" + }, + "./release-intelligence.css": "./dist/release-intelligence.css", "./styles.css": "./dist/styles.css", "./package.json": "./package.json" }, diff --git a/packages/ui/scripts/check-package-size.ts b/packages/ui/scripts/check-package-size.ts index 3849230..925d77e 100644 --- a/packages/ui/scripts/check-package-size.ts +++ b/packages/ui/scripts/check-package-size.ts @@ -84,8 +84,8 @@ const allowedFilePatterns = [ /^package\.json$/u, /^dist\/[^/]+\.js$/u, /^dist\/[^/]+\.d\.ts$/u, - // The stylesheet, shipped as a resolvable asset (exports["./styles.css"]). - /^dist\/styles\.css$/u, + // Stylesheets shipped as explicit package exports. + /^dist\/(?:styles|release-intelligence)\.css$/u, ]; for (const file of pack.files) { diff --git a/packages/ui/src/css.d.ts b/packages/ui/src/css.d.ts new file mode 100644 index 0000000..e2b48bc --- /dev/null +++ b/packages/ui/src/css.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: Readonly>; + export default classes; +} diff --git a/packages/ui/src/lib/client-bundle-boundary.test.ts b/packages/ui/src/lib/client-bundle-boundary.test.ts index eb328c9..cbff5d5 100644 --- a/packages/ui/src/lib/client-bundle-boundary.test.ts +++ b/packages/ui/src/lib/client-bundle-boundary.test.ts @@ -12,10 +12,12 @@ import { describe, expect, it } from "vitest"; const dir = fileURLToPath(new URL(".", import.meta.url)); const componentsDir = fileURLToPath(new URL("../components/", import.meta.url)); +const releaseIntelligenceDir = fileURLToPath(new URL("../release-intelligence/", import.meta.url)); const sources = [ ...readdirSync(dir).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)).map((f) => `${dir}${f}`), ...readdirSync(componentsDir).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)).map((f) => `${componentsDir}${f}`), + ...readdirSync(releaseIntelligenceDir).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)).map((f) => `${releaseIntelligenceDir}${f}`), ]; const staticValueBarrel = /import\s+(?!type\b)[^;]*?from\s+["']@shiplightai\/quality-core["']/g; diff --git a/packages/ui/src/release-intelligence/AnalysisAutoRefresh.test.tsx b/packages/ui/src/release-intelligence/AnalysisAutoRefresh.test.tsx new file mode 100644 index 0000000..91e9b7f --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisAutoRefresh.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AnalysisAutoRefresh } from "./AnalysisAutoRefresh"; + +const refresh = vi.hoisted(() => vi.fn()); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh }), +})); + +describe("AnalysisAutoRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + refresh.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("backs off and stops polling after ten minutes", () => { + render(); + + act(() => vi.advanceTimersByTime(3_000)); + expect(refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(6_000)); + expect(refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(10 * 60 * 1_000)); + const callsAtCap = refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(60 * 60 * 1_000)); + + expect(callsAtCap).toBeGreaterThan(2); + expect(refresh).toHaveBeenCalledTimes(callsAtCap); + }); + + it("does not poll when the attempt is terminal", () => { + render(); + act(() => vi.advanceTimersByTime(60 * 60 * 1_000)); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/release-intelligence/AnalysisAutoRefresh.tsx b/packages/ui/src/release-intelligence/AnalysisAutoRefresh.tsx new file mode 100644 index 0000000..df4df89 --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisAutoRefresh.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** Refreshes server-rendered analysis state with bounded exponential backoff. */ +export function AnalysisAutoRefresh({ active }: { readonly active: boolean }): null { + const router = useRouter(); + + useEffect(() => { + if (!active) return; + + const startedAt = Date.now(); + const maxDuration = 10 * 60 * 1_000; + let delay = 3_000; + let timer: number | undefined; + const refresh = (): void => { + router.refresh(); + delay = Math.min(delay * 2, 30_000); + if (Date.now() - startedAt + delay <= maxDuration) { + timer = window.setTimeout(refresh, delay); + } + }; + + timer = window.setTimeout(refresh, delay); + return () => window.clearTimeout(timer); + }, [active, router]); + + return null; +} diff --git a/packages/ui/src/release-intelligence/AnalysisHealth.test.tsx b/packages/ui/src/release-intelligence/AnalysisHealth.test.tsx new file mode 100644 index 0000000..f6f5add --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisHealth.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { ReleaseAnalysisHealthView } from "@shiplightai/quality-core/release-intelligence"; +import { AnalysisHealth, AnalysisHealthSummary } from "./AnalysisHealth"; + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); +}); + +afterEach(() => cleanup()); + +const detail: ReleaseAnalysisHealthView = { + systemFacts: [ + { + id: "fact-1", + factKey: "repository-facts-complete", + status: "failed", + severity: "critical", + summary: "1 repository fact problem prevented a complete scan.", + exceptionEligible: false, + findings: [ + { + code: "MISSING_EVIDENCE_FILE", + message: "The configured evidence path does not exist.", + remediation: "Restore, replace, or remove the stale evidence declaration.", + declarationPath: ".quality/evidence/cli/quality-map.yaml", + affectedPath: "apps/cli/test.ts", + }, + ], + }, + ], +}; + +describe("AnalysisHealth", () => { + it("summarizes the concrete problem and opens analysis health", () => { + const onViewDetails = vi.fn(); + render( + + + , + ); + + expect(screen.getByText("Analysis inputs incomplete: 1 problem")).toBeInTheDocument(); + expect(screen.getByText("Configured evidence file is missing")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View problem details" })); + expect(onViewDetails).toHaveBeenCalledOnce(); + }); + + it("shows a compact system fact row that opens its drawer", () => { + const onViewDetails = vi.fn(); + render( + + + , + ); + + expect(screen.getByText("1 problem found · View details ›")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View Repository configuration details" })); + expect(onViewDetails).toHaveBeenCalledWith("fact-1"); + }); +}); diff --git a/packages/ui/src/release-intelligence/AnalysisHealth.tsx b/packages/ui/src/release-intelligence/AnalysisHealth.tsx new file mode 100644 index 0000000..33d47a7 --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisHealth.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Alert, Badge, Button, Card, Group, Stack, Text, UnstyledButton } from "@mantine/core"; +import { + parseReleaseSystemFactFindings, + systemFactFindingPresentation, + type ReleaseAnalysisHealthView, +} from "@shiplightai/quality-core/release-intelligence"; + +export function AnalysisHealthSummary({ + detail, + onViewDetails, +}: { + readonly detail: ReleaseAnalysisHealthView; + readonly onViewDetails: () => void; +}): React.ReactElement | null { + const failed = detail.systemFacts.filter((fact) => fact.status === "failed"); + if (failed.length === 0) return null; + const findings = failed.flatMap((fact) => parseReleaseSystemFactFindings(fact.findings)); + const first = findings[0]; + const firstPresentation = first ? systemFactFindingPresentation(first) : null; + const problemCount = findings.length || failed.length; + + return ( + + + {firstPresentation && ( + + {firstPresentation.title} + + )} + + {firstPresentation?.explanation ?? first?.message ?? failed[0]?.summary} + + + + + ); +} + +export function AnalysisHealth({ + detail, + onViewDetails, +}: { + readonly detail: ReleaseAnalysisHealthView; + readonly onViewDetails: (systemFactId: string) => void; +}): React.ReactElement { + return ( + + + Analysis health + + These checks show whether repository configuration and workflow inputs were complete + enough for a trustworthy release decision. They are separate from product Features. Select + a check to inspect its details. + + + {detail.systemFacts.map((fact) => { + const findings = parseReleaseSystemFactFindings(fact.findings); + return ( + onViewDetails(fact.id)} + aria-label={`View ${systemFactLabel(fact.factKey)} details`} + > + + +
+ {systemFactLabel(fact.factKey)} + + {fact.status === "passed" + ? "This check completed without problems." + : `${findings.length} ${findings.length === 1 ? "problem prevents" : "problems prevent"} a reliable release decision.`} + + + {fact.status === "passed" + ? "No problems found" + : `${findings.length} ${findings.length === 1 ? "problem" : "problems"} found`}{" "} + · View details › + +
+ + {fact.status === "passed" ? "Healthy" : "Needs attention"} + +
+
+
+ ); + })} +
+ ); +} + +export function systemFactLabel(key: string): string { + if (key === "repository-facts-complete") return "Repository configuration"; + if (key === "workflow-evidence-complete") return "Workflow evidence collection"; + return key.replaceAll("-", " "); +} diff --git a/packages/ui/src/release-intelligence/AnalysisHistory.test.tsx b/packages/ui/src/release-intelligence/AnalysisHistory.test.tsx new file mode 100644 index 0000000..e702b22 --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisHistory.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import type { ReleaseAnalysisHistoryView } from "@shiplightai/quality-core/release-intelligence"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { AnalysisHistory } from "./AnalysisHistory"; + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); +}); + +const detail: ReleaseAnalysisHistoryView = { + attempts: [ + { + id: "attempt-2", + attemptNumber: 2, + triggerType: "manual_reanalysis", + status: "allow", + analyzerVersion: "0.3.1", + factSetHash: "abcdef1234567890", + decision: "ALLOW", + summary: null, + diagnostics: [], + startedAt: "2026-09-09T00:00:00.000Z", + completedAt: "2026-09-09T00:01:00.000Z", + createdAt: "2026-09-09T00:00:00.000Z", + }, + { + id: "attempt-1", + attemptNumber: 1, + triggerType: "workflow_gate", + status: "collecting", + analyzerVersion: "0.3.0", + factSetHash: null, + decision: null, + summary: null, + diagnostics: [], + startedAt: null, + completedAt: null, + createdAt: "2026-09-08T00:00:00.000Z", + }, + ], +}; + +describe("AnalysisHistory", () => { + it("renders immutable attempts from serialized timestamps", () => { + render( + + + , + ); + + expect(screen.getByText("Attempt 2")).toBeInTheDocument(); + expect(screen.getByText("manual re-analysis")).toBeInTheDocument(); + expect(screen.getByText("facts abcdef123456")).toBeInTheDocument(); + expect(screen.getByText(/Started not yet · completed not yet/)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/release-intelligence/AnalysisHistory.tsx b/packages/ui/src/release-intelligence/AnalysisHistory.tsx new file mode 100644 index 0000000..25622ee --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalysisHistory.tsx @@ -0,0 +1,75 @@ +import { Badge, Card, Group, Stack, Text, Timeline, Title } from "@mantine/core"; +import type { ReleaseAnalysisHistoryView } from "@shiplightai/quality-core/release-intelligence"; + +export function AnalysisHistory({ + detail, +}: { + readonly detail: ReleaseAnalysisHistoryView; +}): React.ReactElement { + return ( + + Immutable analysis attempts + + Every analysis is an immutable attempt. Publish status is recorded independently. + + + {detail.attempts.map((attempt) => ( + + Attempt {attempt.attemptNumber} + {attempt.triggerType === "workflow_gate" && ( + + workflow + + )} + {attempt.triggerType === "historical_import" && ( + + historical import + + )} + {attempt.triggerType === "manual_reanalysis" && ( + + manual re-analysis + + )} + + {attempt.status} + + + } + > + + + {attempt.triggerType.replaceAll("_", " ")} · analyzer {attempt.analyzerVersion} + + + Started {formatTimestamp(attempt.startedAt)} · completed{" "} + {formatTimestamp(attempt.completedAt)} + + {attempt.factSetHash && ( + + facts {attempt.factSetHash.slice(0, 12)} + + )} + + + ))} + + + ); +} + +function formatTimestamp(value: string | null): string { + return value === null ? "not yet" : new Date(value).toLocaleString("en-US"); +} diff --git a/packages/ui/src/release-intelligence/AnalyzeExistingWorkflow.tsx b/packages/ui/src/release-intelligence/AnalyzeExistingWorkflow.tsx new file mode 100644 index 0000000..5bf49d0 --- /dev/null +++ b/packages/ui/src/release-intelligence/AnalyzeExistingWorkflow.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { + Alert, + Anchor, + Badge, + Button, + Card, + Group, + Modal, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useReleaseApi, useReleaseRoute } from "./host"; +import type { ReleaseRepositoryOption } from "./RepositoryFilter"; + +interface WorkflowPreview { + readonly repository: string; + readonly workflow_run_id: string; + readonly workflow_run_attempt: number; + readonly workflow_name: string; + readonly workflow_url: string; + readonly commit_sha: string; + readonly conclusion: string | null; + readonly release_records: readonly { + readonly id: string; + readonly environment: string; + readonly publish_status: string; + readonly components: readonly string[]; + }[]; +} + +export function AnalyzeExistingWorkflow({ + repos, +}: { + readonly repos: readonly ReleaseRepositoryOption[]; +}): React.ReactElement { + const api = useReleaseApi(); + const releaseRoute = useReleaseRoute(); + const router = useRouter(); + const [opened, setOpened] = useState(false); + const [reference, setReference] = useState(""); + const [repoId, setRepoId] = useState(repos.length === 1 ? repos[0]!.id : null); + const [preview, setPreview] = useState(); + const [environment, setEnvironment] = useState<"staging" | "production">("production"); + const [components, setComponents] = useState(""); + const [error, setError] = useState(); + const [resolvePending, startResolveTransition] = useTransition(); + const [startPending, startAttemptTransition] = useTransition(); + const [importPending, startImportTransition] = useTransition(); + const anyPending = resolvePending || startPending || importPending; + const close = (): void => { + setOpened(false); + setError(undefined); + setPreview(undefined); + setComponents(""); + }; + const navigateToRelease = (releaseId: string): void => { + close(); + router.push(releaseRoute(`/releases/${releaseId}`)); + router.refresh(); + }; + + const resolveWorkflow = (): void => { + startResolveTransition(async () => { + setError(undefined); + setPreview(undefined); + if (!repoId) { + setError("Select a connected repository."); + return; + } + const response = await fetch(api("/workflow/resolve"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workflow_url_or_id: reference, connected_repo_id: repoId }), + }); + const body = (await response.json()) as WorkflowPreview & { readonly title?: string }; + if (!response.ok) setError(body.title ?? "Could not resolve workflow."); + else setPreview(body); + }); + }; + const startAttempt = (releaseId: string): void => { + startAttemptTransition(async () => { + setError(undefined); + const response = await fetch(api(`/releases/${releaseId}/attempts`), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workflow_url_or_id: reference, + idempotency_key: globalThis.crypto.randomUUID(), + }), + }); + const body = (await response.json()) as { readonly title?: string }; + if (!response.ok) setError(body.title ?? "Could not start analysis."); + else navigateToRelease(releaseId); + }); + }; + const importAndAnalyze = (): void => { + startImportTransition(async () => { + setError(undefined); + if (!repoId) { + setError("Select a connected repository."); + return; + } + const response = await fetch(api("/workflow/import"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workflow_url_or_id: reference, + connected_repo_id: repoId, + environment, + components: parseComponents(components), + idempotency_key: globalThis.crypto.randomUUID(), + }), + }); + const body = (await response.json()) as { + readonly release_id?: string; + readonly title?: string; + }; + if (!response.ok || !body.release_id) { + setError(body.title ?? "Could not import and analyze workflow."); + } else navigateToRelease(body.release_id); + }); + }; + + return ( + <> + + + + + Select the connected repository and enter a GitHub Actions run URL or ID. The host + previews its exact commit and existing Release Record before creating an immutable + attempt. + + ({ value: feature.id, label: feature.name }))} + allowDeselect={false} + className="ri-feature-mobile-select" + /> + + + + + {detail.features.length} Features + {detail.behaviors.length} expected Behaviors + +
+ {detail.features.map((feature) => { + const behaviors = behaviorsByFeature.get(feature.id) ?? []; + const assessments = behaviors.flatMap(({ behavior }) => { + const assessment = assessmentByBehavior.get(behavior.id); + return assessment ? [assessment] : []; + }); + const issues = assessments.filter( + (item) => !["verified", "not_applicable"].includes(item.status), + ).length; + const selected = feature.id === selectedFeature.id; + return ( + selectFeature(feature.id)} + w="100%" + px="md" + py="sm" + mih={56} + className="ri-feature-option" + > + +
+ {feature.name} + {behaviors.length} Behaviors +
+ 0 ? "orange" : "green"} ta="right"> + {issues > 0 ? `${issues} issue${issues === 1 ? "" : "s"}` : "Clear"} + +
+
+ ); + })} +
+
+ + + + +
+ {selectedFeature.name} + {selectedFeature.description} +
+ {featureSource && ( + + Feature source ↗ + + )} +
+ + + + + total + (evidenceCountByAssessment.get(assessment.id) ?? 0), + 0, + )} + label="evidence records" + /> + +
+ +
+ {visibleBehaviors.length === 0 ? ( + + No Behaviors match this filter. + + ) : ( + visibleBehaviors.map(({ behavior }) => { + const assessment = assessmentByBehavior.get(behavior.id); + const issue = assessment ? issueByAssessment.get(assessment.id) : undefined; + const evidenceCount = assessment + ? (evidenceCountByAssessment.get(assessment.id) ?? 0) + : 0; + const status = assessment?.status ?? "pending"; + const color = + status === "verified" + ? "green" + : status === "failed" || status === "check_error" + ? "red" + : "orange"; + const subtitle = assessment?.missingProof.length + ? `Missing proof: ${assessment.missingProof.join(", ")}` + : assessment + ? `${evidenceCount} evidence record${evidenceCount === 1 ? "" : "s"} · ${assessment.runtimeStatus.replaceAll("_", " ")}` + : "Assessment pending"; + return ( + assessment && onViewAssessment(assessment.id)} + disabled={!assessment} + w="100%" + className="ri-feature-behavior-button" + > + + + + {status === "verified" ? "✓" : status === "failed" ? "×" : "!"} + + + {behavior.title} + {subtitle} + {issue && {issue.recommendedAction}} + + + {behavior.origin} › + + + + + ); + }) + )} +
+
+
+
+ ); +} + +function SummaryValue({ value, label }: { readonly value: number; readonly label: string }): React.ReactElement { + return ( + + {value}{" "} + {label} + + ); +} diff --git a/packages/ui/src/release-intelligence/IssueEvidenceDrawer.css b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.css new file mode 100644 index 0000000..b0fde7f --- /dev/null +++ b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.css @@ -0,0 +1,41 @@ +.ri-drawer-header { + min-height: 44px; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); +} + +.ri-drawer-title { + font-size: var(--mantine-font-size-sm); + font-weight: 600; + line-height: 1.25; +} + +.ri-drawer-body { + padding-top: var(--mantine-spacing-sm); +} + +.ri-drawer-section { + padding-top: var(--mantine-spacing-md); + border-top: 1px solid var(--mantine-color-default-border); +} + +.ri-drawer-row + .ri-drawer-row { + border-top: 1px solid var(--mantine-color-default-border); +} + +.ri-drawer-technical-details { + margin-top: var(--mantine-spacing-md); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-default); +} + +.ri-drawer-technical-summary { + padding: var(--mantine-spacing-sm); + cursor: pointer; + font-size: var(--mantine-font-size-sm); + font-weight: 600; +} + +.ri-drawer-technical-body { + padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-sm); +} diff --git a/packages/ui/src/release-intelligence/IssueEvidenceDrawer.test.tsx b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.test.tsx new file mode 100644 index 0000000..c09f632 --- /dev/null +++ b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import type { ReleaseIssueEvidenceView } from "@shiplightai/quality-core/release-intelligence"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { IssueEvidenceDrawer } from "./IssueEvidenceDrawer"; + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); +}); + +const reason = "Every required proof facet is supported by admissible evidence."; +const detail: ReleaseIssueEvidenceView = { + repository: "ShiplightAI/store", + release: { commitSha: "c".repeat(40) }, + features: [ + { + id: "feature-1", + featureKey: "checkout", + name: "Checkout", + description: "", + priority: "P0", + status: "confirmed", + sourceRefs: [], + diagnostics: [], + }, + ], + behaviors: [ + { + featureId: "feature-1", + behavior: { + id: "behavior-1", + behaviorKey: "checkout:succeeds", + title: "Checkout succeeds", + description: "Customers can complete checkout.", + priority: "P0", + origin: "explicit", + reviewStatus: "confirmed", + sourceRefs: [], + requiredProof: [], + applicable: true, + }, + }, + ], + assessments: [ + { + id: "assessment-1", + behaviorSnapshotId: "behavior-1", + status: "verified", + runtimeStatus: "passed", + observedProof: ["Checkout succeeds at runtime"], + missingProof: [], + reason, + }, + ], + issues: [], + assessmentEvidence: [ + { + assessmentId: "assessment-1", + evidenceRecordId: "evidence-1", + relationship: "direct", + reason: "Exact test result match.", + }, + ], + evidence: [ + { + id: "evidence-1", + evidenceKey: "checkout-test", + kind: "test", + sourceName: "checkout test", + runtimeStatus: "passed", + identityStatus: "matched", + collectionStatus: "available", + providerRef: "/runs/101?test=1001", + fileRef: null, + archiveRef: null, + contentHash: null, + details: {}, + }, + ], + systemFacts: [], + rules: [], +}; + +describe("IssueEvidenceDrawer", () => { + it("shows an assessment reason once and preserves navigable provider links", () => { + render( + + + , + ); + + expect(screen.getAllByText(reason)).toHaveLength(1); + expect(screen.getByRole("link", { name: "Open run test ↗" })).toHaveAttribute( + "href", + "/runs/101?test=1001", + ); + }); + + it("does not render untrusted external evidence links", () => { + const untrustedDetail = { + ...detail, + evidence: [ + { + ...detail.evidence[0]!, + providerRef: "https://malicious.example/phishing", + }, + ], + }; + + render( + + + , + ); + + expect(screen.queryByRole("link", { name: "Open evidence ↗" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/release-intelligence/IssueEvidenceDrawer.tsx b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.tsx new file mode 100644 index 0000000..e409bf2 --- /dev/null +++ b/packages/ui/src/release-intelligence/IssueEvidenceDrawer.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { Anchor, Badge, Drawer, Group, ScrollArea, Stack, Text, Title } from "@mantine/core"; +import { + buildReleaseIssueFixPromptForView, + githubSourceUrl, + type ReleaseIssueEvidenceView, +} from "@shiplightai/quality-core/release-intelligence"; +import type { ReactNode } from "react"; +import { CopyFixPromptButton } from "./CopyFixPromptButton"; +import "./IssueEvidenceDrawer.css"; + +export function IssueEvidenceDrawer({ + detail, + issueId = null, + assessmentId = null, + onClose, +}: { + readonly detail: ReleaseIssueEvidenceView; + readonly issueId?: string | null; + readonly assessmentId?: string | null; + readonly onClose: () => void; +}): React.ReactElement { + const requestedIssue = detail.issues.find((item) => item.id === issueId); + const assessment = requestedIssue?.assessmentId + ? detail.assessments.find((item) => item.id === requestedIssue.assessmentId) + : detail.assessments.find((item) => item.id === assessmentId); + const issue = + requestedIssue ?? + (assessment ? detail.issues.find((item) => item.assessmentId === assessment.id) : undefined); + const fixPrompt = issue ? buildReleaseIssueFixPromptForView(detail, issue) : undefined; + const behaviorItem = assessment + ? detail.behaviors.find((item) => item.behavior.id === assessment.behaviorSnapshotId) + : undefined; + const feature = behaviorItem + ? detail.features.find((item) => item.id === behaviorItem.featureId) + : undefined; + const evidenceLinks = assessment + ? detail.assessmentEvidence.filter((item) => item.assessmentId === assessment.id) + : []; + const linkedEvidence = evidenceLinks.flatMap((link) => { + const evidence = detail.evidence.find((item) => item.id === link.evidenceRecordId); + return evidence ? [{ evidence, link }] : []; + }); + const appliedRules = assessment + ? detail.rules.filter((rule) => ruleAssessmentIds(rule.inputs).includes(assessment.id)) + : []; + const blocksRelease = + issue?.blocksWithoutException ?? appliedRules.some((rule) => rule.effect === "block"); + const status = assessment?.status ?? issue?.kind ?? "analysis issue"; + + return ( + + {(issue || assessment) && ( + + + {[feature?.name, behaviorItem?.behavior.title].filter(Boolean).join(" / ") || + "Release analysis"} + + + {status.replaceAll("_", " ")} ·{" "} + {status === "verified" ? "verified" : blocksRelease ? "blocks release" : "warning"} + + + {issue?.title ?? behaviorItem?.behavior.title ?? "Behavior assessment"} + + + {issue?.reason ?? assessment?.reason} + + {issue && fixPrompt && ( + + + + )} + + + + {behaviorItem?.behavior.description || + behaviorItem?.behavior.title || + "This issue is produced by an analysis-level diagnostic."} + + {(behaviorItem?.behavior.sourceRefs ?? []).map((source) => ( + + + {source.label ?? source.path} ↗ + + + ))} + + + + {(assessment?.observedProof ?? []).map((proof) => ( + ✓ {proof} + ))} + {(assessment?.missingProof ?? []).map((proof) => ( + ✕ {proof} + ))} + {linkedEvidence.map(({ evidence, link }) => { + const providerLink = evidenceProviderLink(evidence.providerRef); + return ( + + + + {evidence.sourceName} + + {evidence.runtimeStatus.toUpperCase()} + + + {link.relationship} · {link.reason} + + Identity {evidence.identityStatus} · Collection {evidence.collectionStatus} + + + {providerLink && ( + + {providerLink.label} ↗ + + )} + {evidence.fileRef?.path && ( + + Open file at commit ↗ + + )} + + + + ); + })} + {!assessment?.observedProof.length && + !assessment?.missingProof.length && + linkedEvidence.length === 0 && ( + + No evidence record is linked to this analysis issue. + + )} + + + + + + + {behaviorItem && ( + + )} + + + {appliedRules.length > 0 && ( + + {appliedRules.map((rule) => ( + + +
+ {rule.ruleKey} + {rule.reason} +
+ + {rule.status} + +
+
+ ))} +
+ )} + + {issue && ( + + {issue.recommendedAction} + + )} +
+ )} +
+ ); +} + +function EvidenceSection({ children, title }: { readonly children: ReactNode; readonly title: string }): React.ReactElement { + return ( + + {title} + {children} + + ); +} + +function EvidenceRow({ children, tone }: { readonly children: ReactNode; readonly tone?: "positive" | "negative" }): React.ReactElement { + return ( + + {children} + + ); +} + +function KeyValueRow({ label, value }: { readonly label: string; readonly value: string }): React.ReactElement { + return ( + + {label} + {value} + + ); +} + +function statusColor(status: string): string { + return status === "passed" + ? "green" + : status === "failed" || status === "errored" + ? "red" + : "orange"; +} + +function evidenceProviderLink(providerRef: string | null): { href: string; label: string } | null { + if (!providerRef) return null; + if (/^\/runs\/\d+\?test=\d+$/.test(providerRef)) { + return { href: providerRef, label: "Open run test" }; + } + if (/^\/runs\/\d+$/.test(providerRef)) { + return { href: providerRef, label: "Open test run" }; + } + try { + const url = new URL(providerRef); + return url.protocol === "https:" && url.hostname === "github.com" + ? { href: url.href, label: "Open evidence" } + : null; + } catch { + return null; + } +} + +function ruleAssessmentIds(inputs: Readonly>): readonly string[] { + const value = inputs.assessmentIds; + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : []; +} diff --git a/packages/ui/src/release-intelligence/ReleaseDetail.css b/packages/ui/src/release-intelligence/ReleaseDetail.css new file mode 100644 index 0000000..30bf4dc --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseDetail.css @@ -0,0 +1,43 @@ +.ri-detail-workspace { + flex: 1; + min-height: 0; +} + +.ri-detail-release-context, +.ri-detail-tab-list { + flex-shrink: 0; +} + +.ri-detail-tabs { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.ri-detail-tab-content { + flex: 1; + min-height: 0; + margin-inline-end: calc(0px - var(--mantine-spacing-md)); + padding-inline-end: var(--mantine-spacing-md); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.ri-detail-feature-panel { + height: 100%; + overflow: hidden; +} + +.ri-detail-decision-row { + border-bottom: 1px solid var(--mantine-color-default-border); +} + +@media (max-width: 61.99em) { + .ri-detail-feature-panel { + height: auto; + min-height: 100%; + overflow: visible; + } +} diff --git a/packages/ui/src/release-intelligence/ReleaseDetail.test.tsx b/packages/ui/src/release-intelligence/ReleaseDetail.test.tsx new file mode 100644 index 0000000..902ffb4 --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseDetail.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReleaseDetailView } from "@shiplightai/quality-core/release-intelligence"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { ReleaseDetail } from "./ReleaseDetail"; +import { ReleaseUiHostProvider } from "./host"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +beforeAll(() => { + HTMLElement.prototype.scrollTo = vi.fn(); + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); +}); + +afterEach(() => cleanup()); + +const detail: ReleaseDetailView = { + repository: "ShiplightAI/store", + release: { + id: "release-1", + commitSha: "a".repeat(40), + workflowName: "Production release", + workflowUrl: "https://github.com/ShiplightAI/store/actions/runs/42", + workflowRunId: "42", + environment: "production", + components: ["web"], + publishStatus: "unknown", + }, + features: [ + { + id: "feature-1", + featureKey: "checkout", + name: "Checkout", + description: "Customer checkout", + priority: "P0", + status: "confirmed", + sourceRefs: [], + diagnostics: [], + }, + ], + behaviors: [ + { + featureId: "feature-1", + behavior: { + id: "behavior-1", + behaviorKey: "checkout:succeeds", + title: "Checkout succeeds", + description: null, + priority: "P0", + origin: "explicit", + reviewStatus: "confirmed", + sourceRefs: [], + requiredProof: ["checkout succeeds"], + applicable: true, + }, + }, + ], + assessments: [ + { + id: "assessment-1", + behaviorSnapshotId: "behavior-1", + status: "insufficient_proof", + runtimeStatus: "unknown", + observedProof: [], + missingProof: ["checkout succeeds"], + reason: "No admissible evidence.", + }, + ], + issues: [ + { + id: "issue-1", + assessmentId: "assessment-1", + systemFactId: null, + kind: "insufficient_proof", + severity: "critical", + title: "Checkout is unverified", + reason: "No admissible evidence.", + recommendedAction: "Add exact-run evidence.", + blocksWithoutException: true, + }, + ], + assessmentEvidence: [], + evidence: [], + systemFacts: [], + rules: [ + { + id: "rule-1", + ruleKey: "block-unverified-p0", + status: "fail", + inputs: { assessmentIds: ["assessment-1"] }, + effect: "block", + reason: "A P0 behavior lacks proof.", + }, + ], + exceptions: [], + attempts: [ + { + id: "attempt-1", + attemptNumber: 1, + triggerType: "workflow_gate", + status: "block", + analyzerVersion: "release-intelligence/0.1.0", + factSetHash: null, + decision: "BLOCK", + summary: { featureCount: 1, behaviorCount: 1, verifiedCount: 0, issueCount: 1 }, + diagnostics: [], + startedAt: "2026-09-10T00:00:00.000Z", + completedAt: "2026-09-10T00:00:01.000Z", + createdAt: "2026-09-10T00:00:00.000Z", + }, + ], +}; + +describe("ReleaseDetail", () => { + it("renders the complete shared detail workspace", () => { + render( + + + + + , + ); + + expect(screen.getByText("Production release")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Summary" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Features" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Runs & Artifacts" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Release Rules" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Analysis History" })).toBeInTheDocument(); + expect(screen.getByText("Prioritized issues")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Release Rules" })); + expect(screen.getByText("Deterministic decision boundary")).toBeInTheDocument(); + expect(screen.getByText("Owner approval required")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/release-intelligence/ReleaseDetail.tsx b/packages/ui/src/release-intelligence/ReleaseDetail.tsx new file mode 100644 index 0000000..b408818 --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseDetail.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { + Alert, + Anchor, + Badge, + Box, + Code, + Divider, + Group, + Paper, + SimpleGrid, + Stack, + Tabs, + Text, + Title, + UnstyledButton, +} from "@mantine/core"; +import { + githubCommitUrl, + type ReleaseDetailView, +} from "@shiplightai/quality-core/release-intelligence"; +import { useState } from "react"; +import { AnalysisAutoRefresh } from "./AnalysisAutoRefresh"; +import { AnalysisHealthSummary } from "./AnalysisHealth"; +import { AnalysisHistory } from "./AnalysisHistory"; +import { FeatureBrowser } from "./FeatureBrowser"; +import { IssueEvidenceDrawer } from "./IssueEvidenceDrawer"; +import { ReleaseRules } from "./ReleaseRules"; +import { RunsArtifacts } from "./RunsArtifacts"; +import { SystemFactDrawer } from "./SystemFactDrawer"; +import "./ReleaseDetail.css"; + +interface ReleaseSummaryView { + readonly featureCount?: number; + readonly behaviorCount?: number; + readonly verifiedCount?: number; + readonly issueCount?: number; +} + +export function ReleaseDetail({ + detail, + canApproveExceptions = false, +}: { + readonly detail: ReleaseDetailView; + readonly canApproveExceptions?: boolean; +}): React.ReactElement { + const current = detail.attempts[0]; + const decision = current?.decision ?? current?.status ?? "queued"; + const normalizedDecision = decision.toLowerCase(); + const decisionColor = + normalizedDecision === "allow" + ? "green" + : normalizedDecision === "block" || normalizedDecision === "error" + ? "red" + : "yellow"; + const [assessmentId, setAssessmentId] = useState(null); + const [systemFactId, setSystemFactId] = useState(null); + + return ( + + + + + + + + {decision.replaceAll("_", " ")} + + {detail.release.environment} + + + {detail.release.workflowName} + + + Analysis of the exact workflow run and commit. Publish status changes only when an + explicit publish result is recorded. + + + + + + + + + + + {detail.repository} + + · + + Workflow run {detail.release.workflowRunId} + + · + + {detail.release.commitSha.slice(0, 12)} + + · + + {detail.release.components.length > 0 + ? detail.release.components.join(", ") + : "All components"} + + {current && ( + <> + · + Attempt {current.attemptNumber} + + )} + + + + + Summary + Features + Runs & Artifacts + Release Rules + Analysis History + +
+ + + + + + + + + + + + + + + +
+
+ {/* FeatureBrowser owns direct assessment selection; Summary owns issue selection and + therefore renders its separate issue-driven drawer inside that tab. */} + setAssessmentId(null)} + /> + setSystemFactId(null)} + /> +
+ ); +} + +function StatusFact({ label, value }: { readonly label: string; readonly value: string }) { + const color = + value === "allow" || value === "published" || value === "verified" + ? "green" + : value === "block" || value === "failed" || value === "error" + ? "red" + : "yellow"; + return ( + + + {label} + + + {value.replaceAll("_", " ")} + + + ); +} + +function Summary({ detail }: { readonly detail: ReleaseDetailView }): React.ReactElement { + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [selectedSystemFactId, setSelectedSystemFactId] = useState(null); + const selectedIssue = detail.issues.find((item) => item.id === selectedIssueId); + const firstFailedSystemFactId = + detail.systemFacts.find((item) => item.status === "failed")?.id ?? null; + const storedSummary = detail.attempts[0]?.summary; + const summary = parseReleaseSummary(storedSummary); + const componentDiagnostic = detail.attempts[0]?.diagnostics.find( + (diagnostic) => diagnostic.code === "unknown_release_component", + ); + + return ( + <> + + setSelectedSystemFactId(firstFailedSystemFactId)} + /> + {storedSummary !== null && storedSummary !== undefined && summary === null && ( + + The stored summary has an unexpected shape. Detailed evidence remains available below. + + )} + {typeof componentDiagnostic?.message === "string" && ( + + {componentDiagnostic.message} + + )} + + + + + + + + + + + Decision basis + + {detail.rules.length === 0 ? ( + + {detail.attempts[0]?.status === "inconclusive" + ? "No policy rules were evaluated because analysis was inconclusive." + : "Rules will appear when analysis finishes."} + + ) : ( + + {detail.rules.map((rule) => ( + +
+ + {rule.ruleKey} + + + {rule.reason} + +
+ + {rule.status.toUpperCase()} + +
+ ))} +
+ )} +
+
+ + + Prioritized issues + + {detail.issues.length > 0 && ( + + Select an issue to inspect its details + + )} + + {detail.issues.length === 0 ? ( + + + No issues in the selected attempt. + + + ) : ( + + {detail.issues.map((issue) => { + const featureName = getIssueFeatureName(detail, issue.assessmentId); + return ( + { + setSelectedSystemFactId(null); + setSelectedIssueId(issue.id); + }} + w="100%" + > + + + + + + {featureName ? `${featureName} / ${issue.title}` : issue.title} + + + {issue.reason} + + + + {issue.kind.replaceAll("_", " ")} › + + + + + ); + })} + + )} +
+
+ setSelectedIssueId(null)} + /> + { + setSelectedIssueId(null); + setSelectedSystemFactId(null); + }} + /> + + ); +} + +function getIssueFeatureName(detail: ReleaseDetailView, assessmentId: string | null) { + if (!assessmentId) return undefined; + const assessment = detail.assessments.find((item) => item.id === assessmentId); + const behavior = detail.behaviors.find( + (item) => item.behavior.id === assessment?.behaviorSnapshotId, + ); + return detail.features.find((item) => item.id === behavior?.featureId)?.name; +} + +function SummaryStat({ label, value }: { readonly label: string; readonly value: number }) { + return ( + + + {label} + + + {value} + + + ); +} + +function parseReleaseSummary(value: unknown): ReleaseSummaryView | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + const keys = ["featureCount", "behaviorCount", "verifiedCount", "issueCount"] as const; + if ( + keys.some( + (key) => + record[key] !== undefined && + !(typeof record[key] === "number" && Number.isInteger(record[key]) && record[key] >= 0), + ) + ) { + return null; + } + return Object.fromEntries( + keys.flatMap((key) => (record[key] === undefined ? [] : [[key, record[key]]])), + ) as ReleaseSummaryView; +} diff --git a/packages/ui/src/release-intelligence/ReleaseRecordsTable.test.tsx b/packages/ui/src/release-intelligence/ReleaseRecordsTable.test.tsx new file mode 100644 index 0000000..b4f34c2 --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseRecordsTable.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { MantineProvider } from "@mantine/core"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { ReleaseListItemView } from "@shiplightai/quality-core/release-intelligence"; +import { ReleaseRecordsTable } from "./ReleaseRecordsTable"; +import { ReleaseUiHostProvider } from "./host"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/release-checks", + useSearchParams: () => new URLSearchParams("repository=repo-id"), +})); + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(() => cleanup()); + +const record: ReleaseListItemView = { + id: "release-1", + repository: "ShiplightAI/store", + workflowName: "Publish", + source: "workflow_gate", + workflowRunId: "42", + workflowUrl: "https://github.com/ShiplightAI/store/actions/runs/42", + commitSha: "a".repeat(40), + environment: "production", + components: ["web"], + publishStatus: "published", + createdAt: "2026-09-09T00:00:00.000Z", + latestAttempt: { status: "allow", decision: "ALLOW", attemptNumber: 1 }, +}; + +function renderTable(): void { + render( + + + + + , + ); +} + +describe("ReleaseRecordsTable", () => { + it("fails fast when the host composition root is missing", () => { + expect(() => + render( + + + , + ), + ).toThrow( + "Release Intelligence components must be rendered inside .", + ); + }); + + it("uses the host route and immutable commit URL", () => { + renderTable(); + + expect(screen.getByRole("link", { name: "Publish" })).toHaveAttribute( + "href", + "/release-checks/releases/release-1", + ); + expect(screen.getByRole("link", { name: "aaaaaaaa" })).toHaveAttribute( + "href", + `https://github.com/ShiplightAI/store/commit/${"a".repeat(40)}`, + ); + }); + + it("preserves current filters when linking to the next cursor", () => { + renderTable(); + + expect(screen.getByRole("link", { name: /older release checks/i })).toHaveAttribute( + "href", + "/release-checks?repository=repo-id&cursor=next-page", + ); + }); +}); diff --git a/packages/ui/src/release-intelligence/ReleaseRecordsTable.tsx b/packages/ui/src/release-intelligence/ReleaseRecordsTable.tsx new file mode 100644 index 0000000..46840c0 --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseRecordsTable.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Anchor, Badge, Group, Stack, Table, Text } from "@mantine/core"; +import { githubCommitUrl, type ReleaseListItemView } from "@shiplightai/quality-core/release-intelligence"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useReleaseRoute } from "./host"; + +export function ReleaseRecordsTable({ + records, + nextCursor, +}: { + readonly records: readonly ReleaseListItemView[]; + readonly nextCursor: string | null; +}): React.ReactElement { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const releaseRoute = useReleaseRoute(); + const olderHref = nextCursor + ? (() => { + const next = new URLSearchParams(searchParams.toString()); + next.set("cursor", nextCursor); + return `${pathname}?${next.toString()}`; + })() + : null; + + return ( + + + Release checks are ordered newest first. + + + + + + Repository + Workflow + Commit + Target + Analysis + Publish + Created + + + + {records.map((record) => ( + + + + {record.repository} + + + + + + {record.workflowName} + + + Run {record.workflowRunId} + + + + + + {record.commitSha.slice(0, 8)} + + + + + + {record.environment} + + + {record.components.length > 0 + ? record.components.join(", ") + : "All components"} + + + + + + + + + + + + {new Date(record.createdAt).toLocaleString("en-US")} + + + + ))} + +
+
+ {olderHref && ( + + + Older release checks → + + + )} +
+ ); +} + +function StatusBadge({ value }: { readonly value: string }): React.ReactElement { + const normalized = value.toLowerCase(); + const color = + normalized === "allow" || normalized === "published" + ? "green" + : normalized === "block" || normalized === "failed" || normalized === "error" + ? "red" + : "yellow"; + return ( + + {value.replaceAll("_", " ")} + + ); +} diff --git a/packages/ui/src/release-intelligence/ReleaseRules.tsx b/packages/ui/src/release-intelligence/ReleaseRules.tsx new file mode 100644 index 0000000..3b08401 --- /dev/null +++ b/packages/ui/src/release-intelligence/ReleaseRules.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { + Alert, + Badge, + Button, + Card, + Group, + Modal, + Stack, + Table, + Text, + Textarea, + Title, +} from "@mantine/core"; +import type { ReleaseDetailView } from "@shiplightai/quality-core/release-intelligence"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useReleaseApi } from "./host"; + +export function ReleaseRules({ + detail, + canApprove = false, +}: { + readonly detail: ReleaseDetailView; + readonly canApprove?: boolean; +}): React.ReactElement { + const api = useReleaseApi(); + const router = useRouter(); + const [issueId, setIssueId] = useState(); + const [reason, setReason] = useState(""); + const [approvalError, setApprovalError] = useState(); + const [revokeExceptionId, setRevokeExceptionId] = useState(); + const [revokeError, setRevokeError] = useState(); + const [approvalPending, startApprovalTransition] = useTransition(); + const [revokePending, startRevokeTransition] = useTransition(); + const activeIssueIds = new Set( + detail.exceptions.filter((item) => item.isActive).map((item) => item.issueId), + ); + + const closeApprovalModal = (): void => { + setIssueId(undefined); + setReason(""); + setApprovalError(undefined); + }; + const closeRevokeModal = (): void => { + setRevokeExceptionId(undefined); + setRevokeError(undefined); + }; + const approve = (): void => { + if (!issueId || !canApprove) return; + startApprovalTransition(async () => { + setApprovalError(undefined); + const response = await fetch(api(`/releases/${detail.release.id}/exceptions`), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ issue_id: issueId, reason }), + }); + const body = (await response.json()) as { readonly title?: string }; + if (!response.ok) { + setApprovalError(body.title ?? "Could not approve exception."); + return; + } + closeApprovalModal(); + router.refresh(); + }); + }; + const revoke = (): void => { + if (!revokeExceptionId || !canApprove) return; + startRevokeTransition(async () => { + setRevokeError(undefined); + const response = await fetch(api(`/releases/${detail.release.id}/exceptions`), { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ exception_id: revokeExceptionId }), + }); + const body = (await response.json()) as { readonly title?: string }; + if (!response.ok) { + setRevokeError(body.title ?? "Could not revoke exception."); + return; + } + closeRevokeModal(); + router.refresh(); + }); + }; + + return ( + + + AI-assisted parsing may organize facts and evidence. Only the recorded rule set converts + frozen assessments, System Facts, and active exceptions into ALLOW or BLOCK. + + + Policy evaluation + {detail.rules.map((rule) => ( + +
+ {rule.ruleKey} + + {rule.reason} + +
+ + {rule.status} · {rule.effect} + +
+ ))} +
+ + + + + + Issue + Gate effect + Exception + + + + {detail.issues.map((issue) => ( + + + {issue.title} + + {issue.reason} + + + + + {issue.blocksWithoutException ? "block" : "warning"} + + + + {activeIssueIds.has(issue.id) ? ( + active + ) : canApprove && issue.assessmentId ? ( + + ) : issue.assessmentId ? ( + + {canApprove ? "Approval unavailable" : "Owner approval required"} + + ) : ( + + System gate cannot be excepted + + )} + + + ))} + +
+
+
+ {detail.exceptions.length > 0 && ( + + Exception history + {detail.exceptions.map((item) => ( + +
+ {item.reason} + + Expires {formatTimestamp(item.expiresAt)} + {item.revokedAt ? ` · revoked ${formatTimestamp(item.revokedAt)}` : ""} + +
+ {canApprove && item.isActive && ( + + )} +
+ ))} +
+ )} + + + + This does not mark the Behavior verified. It is considered only by a new analysis + attempt and expires after 24 hours. + +