diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index f4b3d9c101..eb4d35a72e 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -10820,6 +10820,67 @@ }, "summary": { "type": "string" + }, + "checkRunReadiness": { + "type": "object", + "nullable": true, + "properties": { + "readinessBand": { + "type": "string", + "enum": [ + "strong", + "developing", + "early" + ] + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "enum": [ + "traceability", + "related_work", + "change_scope", + "validation", + "pr_state", + "queue_pressure" + ] + }, + "label": { + "type": "string" + }, + "band": { + "type": "string", + "enum": [ + "met", + "partial", + "unmet" + ] + }, + "evidence": { + "type": "string" + }, + "action": { + "type": "string" + } + }, + "required": [ + "key", + "label", + "band", + "evidence", + "action" + ] + } + } + }, + "required": [ + "readinessBand", + "components" + ] } }, "required": [ @@ -10833,6 +10894,7 @@ "previewComment", "appliedLabel", "checkRun", + "checkRunReadiness", "installPreview", "warnings", "summary" diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx index 6a5ee1d122..4f48615d63 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -20,6 +20,8 @@ import { import { ActivationPreview } from "@/components/site/app-panels/activation-preview"; import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings"; import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings"; +import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table"; +import type { CheckRunReadinessTableData } from "@/components/site/check-run-readiness-model"; import { StatCard } from "@/components/site/primitives"; import { RefreshMeta } from "@/components/site/refresh-meta"; import { EmptyState, LoadingState, StateBoundary } from "@/components/site/state-views"; @@ -154,6 +156,7 @@ type SettingsPreviewResponse = { previewComment: string | null; appliedLabel: string | null; checkRun: { willCreate: boolean; title: string; detailLevel: string } | null; + checkRunReadiness: CheckRunReadinessTableData | null; installPreview: InstallPreview; warnings: string[]; summary: string; @@ -168,7 +171,9 @@ const MAINTAINER_ROLES = ["maintainer", "owner", "operator"] as const; * dashboard query and the BYOK form from ever mounting for a non-maintainer (defense-in-depth + a clean * message instead of a raw 403). The backend remains the source of truth. */ -export function MaintainerPanel() { +export function MaintainerPanel({ + initialRepoFullName, +}: { initialRepoFullName?: string | undefined } = {}) { const { session, hydrated } = useSession(); const isMaintainer = (session?.roles ?? []).some((role) => MAINTAINER_ROLES.includes(role as (typeof MAINTAINER_ROLES)[number]), @@ -183,10 +188,14 @@ export function MaintainerPanel() { /> ); } - return ; + return ; } -function MaintainerDashboardView() { +function MaintainerDashboardView({ + initialRepoFullName, +}: { + initialRepoFullName?: string | undefined; +}) { const dashboard = useApiResource( "/v1/app/maintainer-dashboard", "Maintainer dashboard", @@ -360,7 +369,10 @@ function MaintainerDashboardView() { - + @@ -373,12 +385,14 @@ function MaintainerDashboardView() { function SurfacePreview({ reviewability, + initialRepoFullName, }: { reviewability: MaintainerDashboard["reviewability"]; + initialRepoFullName?: string | undefined; }) { const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); const [form, setForm] = useState({ - repoFullName: repoOptions[0] ?? "", + repoFullName: initialRepoFullName ?? repoOptions[0] ?? "", scenarioId: "confirmed-miner", title: "Sample pull request", labels: "bug", @@ -396,6 +410,15 @@ function SurfacePreview({ } }, [form.repoFullName, repoOptions]); + useEffect(() => { + if (!initialRepoFullName) return; + setForm((current) => + current.repoFullName === initialRepoFullName + ? current + : { ...current, repoFullName: initialRepoFullName }, + ); + }, [initialRepoFullName]); + async function runPreview(nextForm = form) { const target = splitRepoFullName(nextForm.repoFullName); if (!target) { @@ -682,6 +705,13 @@ function PreviewResult({ ) : null} + {preview.checkRun?.willCreate ? ( + + ) : null} +
diff --git a/apps/gittensory-ui/src/components/site/check-run-readiness-model.ts b/apps/gittensory-ui/src/components/site/check-run-readiness-model.ts new file mode 100644 index 0000000000..f1a6cf74fb --- /dev/null +++ b/apps/gittensory-ui/src/components/site/check-run-readiness-model.ts @@ -0,0 +1,49 @@ +// Check-run details-page readiness table model (#2216). Pure helpers + types for the Context check +// details slice — mirrors the public-safe band shape from buildExtensionPrStatus (src/signals/ +// extension-contributor-context.ts) so the UI never renders raw readiness scores. + +export type CheckRunDetailLevel = "minimal" | "standard" | "deep"; + +export type ReadinessComponentBand = "met" | "partial" | "unmet"; + +export type ContributorReadinessBand = "strong" | "developing" | "early"; + +export type CheckRunReadinessRow = { + key: string; + label: string; + band: ReadinessComponentBand; + evidence: string; + action: string; +}; + +export type CheckRunReadinessTableData = { + readinessBand: ContributorReadinessBand; + components: CheckRunReadinessRow[]; +}; + +/** Context check details publish the readiness table only at standard/deep detail levels. */ +export function shouldShowCheckRunReadinessTable(detailLevel: CheckRunDetailLevel): boolean { + return detailLevel !== "minimal"; +} + +/** Gate the table on detail level AND the presence of readiness rows (empty set → hide). */ +export function resolveCheckRunReadinessView(args: { + detailLevel: CheckRunDetailLevel | null | undefined; + readiness: CheckRunReadinessTableData | null | undefined; +}): CheckRunReadinessTableData | null { + if (!args.detailLevel || !shouldShowCheckRunReadinessTable(args.detailLevel)) return null; + if (!args.readiness || args.readiness.components.length === 0) return null; + return args.readiness; +} + +export const READINESS_BAND_LABEL: Record = { + strong: "Strong", + developing: "Developing", + early: "Early", +}; + +export const COMPONENT_BAND_LABEL: Record = { + met: "Met", + partial: "Partial", + unmet: "Unmet", +}; diff --git a/apps/gittensory-ui/src/components/site/check-run-readiness-table.test.tsx b/apps/gittensory-ui/src/components/site/check-run-readiness-table.test.tsx new file mode 100644 index 0000000000..5335f1dc10 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/check-run-readiness-table.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table"; +import { + resolveCheckRunReadinessView, + shouldShowCheckRunReadinessTable, + type CheckRunReadinessTableData, +} from "@/components/site/check-run-readiness-model"; + +const SAMPLE: CheckRunReadinessTableData = { + readinessBand: "developing", + components: [ + { + key: "validation", + label: "Validation posture", + band: "partial", + evidence: "Test plan noted but not verified.", + action: "Run the documented test plan.", + }, + ], +}; + +describe("shouldShowCheckRunReadinessTable", () => { + it("shows at standard and deep, hides at minimal", () => { + expect(shouldShowCheckRunReadinessTable("minimal")).toBe(false); + expect(shouldShowCheckRunReadinessTable("standard")).toBe(true); + expect(shouldShowCheckRunReadinessTable("deep")).toBe(true); + }); +}); + +describe("resolveCheckRunReadinessView", () => { + it("returns null below standard detail level even when readiness is present", () => { + expect(resolveCheckRunReadinessView({ detailLevel: "minimal", readiness: SAMPLE })).toBeNull(); + }); + + it("returns null for an empty readiness component set at standard (both gate-off and gate-on shapes)", () => { + const empty: CheckRunReadinessTableData = { readinessBand: "early", components: [] }; + expect(resolveCheckRunReadinessView({ detailLevel: "standard", readiness: empty })).toBeNull(); + expect(resolveCheckRunReadinessView({ detailLevel: "deep", readiness: empty })).toBeNull(); + }); + + it("returns the readiness payload at standard when components are present", () => { + expect(resolveCheckRunReadinessView({ detailLevel: "standard", readiness: SAMPLE })).toEqual( + SAMPLE, + ); + }); +}); + +describe("CheckRunReadinessTable", () => { + it("renders the table at standard detail level", () => { + render(); + expect(screen.getByText("Context check readiness")).toBeTruthy(); + expect(screen.getByText("Validation posture")).toBeTruthy(); + expect(screen.getByText("Test plan noted but not verified.")).toBeTruthy(); + expect(screen.getByText("Developing")).toBeTruthy(); + expect(screen.getByText("Partial")).toBeTruthy(); + }); + + it("hides the table at minimal detail level", () => { + render(); + expect(screen.queryByText("Context check readiness")).toBeNull(); + }); + + it("hides the table when the readiness component set is empty", () => { + render( + , + ); + expect(screen.queryByText("Context check readiness")).toBeNull(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/check-run-readiness-table.tsx b/apps/gittensory-ui/src/components/site/check-run-readiness-table.tsx new file mode 100644 index 0000000000..b8f0cfcc93 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/check-run-readiness-table.tsx @@ -0,0 +1,90 @@ +import { StatusPill, type Status } from "@/components/site/control-primitives"; +import { + COMPONENT_BAND_LABEL, + READINESS_BAND_LABEL, + resolveCheckRunReadinessView, + type CheckRunDetailLevel, + type CheckRunReadinessTableData, + type ContributorReadinessBand, + type ReadinessComponentBand, +} from "@/components/site/check-run-readiness-model"; +import { cn } from "@/lib/utils"; + +const READINESS_BAND_TONE: Record = { + strong: "ready", + developing: "warn", + early: "info", +}; + +const COMPONENT_BAND_TONE: Record = { + met: "ready", + partial: "warn", + unmet: "blocked", +}; + +/** + * Scannable readiness table for the Context check details page (#2216). Consumes the public-safe + * band payload from settings-preview (`checkRunReadiness`); hidden below `standard` detail level. + */ +export function CheckRunReadinessTable({ + detailLevel, + readiness, + className, +}: { + detailLevel: CheckRunDetailLevel | null | undefined; + readiness: CheckRunReadinessTableData | null | undefined; + className?: string; +}) { + const view = resolveCheckRunReadinessView({ detailLevel, readiness }); + if (!view) return null; + + return ( +
+
+
+

+ Context check readiness +

+

+ Public-safe bands from the same readiness rubric as the PR panel — no raw scores. +

+
+ + {READINESS_BAND_LABEL[view.readinessBand]} + +
+ +
+ + + + + + + + + + + {view.components.map((row) => ( + + + + + + + ))} + +
SignalBandEvidenceAction
{row.label} + + {COMPONENT_BAND_LABEL[row.band]} + + {row.evidence} + {row.action} +
+
+
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.index.tsx b/apps/gittensory-ui/src/routes/app.index.tsx index f903c91bf9..5f70627b4c 100644 --- a/apps/gittensory-ui/src/routes/app.index.tsx +++ b/apps/gittensory-ui/src/routes/app.index.tsx @@ -1,4 +1,5 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; import { ArrowRight, Activity, @@ -103,6 +104,20 @@ function AppOverview() { const { session } = useSession(); const { status, connection } = useApiStatus(); const overview = useApiResource("/v1/app/overview", "App overview"); + const navigate = useNavigate(); + + // Context check details_url uses /app?view=maintainer&repo=… — route to the maintainer console (#2216). + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("view") !== "maintainer") return; + const repo = params.get("repo")?.trim(); + void navigate({ + to: "/app/maintainer", + search: repo ? { repo } : {}, + replace: true, + }); + }, [navigate]); + if (!session) return null; const live = connection === "online" && (status === "ok" || status === "degraded"); diff --git a/apps/gittensory-ui/src/routes/app.maintainer.tsx b/apps/gittensory-ui/src/routes/app.maintainer.tsx index bda0d717ee..72c4725650 100644 --- a/apps/gittensory-ui/src/routes/app.maintainer.tsx +++ b/apps/gittensory-ui/src/routes/app.maintainer.tsx @@ -1,13 +1,21 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { MaintainerPanel } from "@/components/site/app-panels/maintainer-panel"; import { PageHeader } from "@/components/site/primitives"; +const searchSchema = z.object({ + repo: z.string().optional(), +}); + export const Route = createFileRoute("/app/maintainer")({ + validateSearch: (search) => searchSchema.parse(search), component: MaintainerRoute, }); function MaintainerRoute() { + const { repo } = Route.useSearch(); + return (
- +
); } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 48e4a4c849..6ffda995ec 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -933,6 +933,20 @@ export const RepoSettingsPreviewSchema = z detailLevel: z.enum(["minimal", "standard", "deep"]), }) .nullable(), + checkRunReadiness: z + .object({ + readinessBand: z.enum(["strong", "developing", "early"]), + components: z.array( + z.object({ + key: z.enum(["traceability", "related_work", "change_scope", "validation", "pr_state", "queue_pressure"]), + label: z.string(), + band: z.enum(["met", "partial", "unmet"]), + evidence: z.string(), + action: z.string(), + }), + ), + }) + .nullable(), installPreview: z.object({ status: z.enum(["ready", "needs_attention", "blocked"]), summary: z.string(), diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index cad577bf06..cd32be3b8b 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -10,9 +10,11 @@ import { buildContributorProfile, buildPreflightResult, buildPublicPrIntelligenceComment, + buildPublicReadinessScore, buildQueueHealth, type ContributorDetection, } from "./engine"; +import { buildExtensionPrStatus, type ExtensionPrStatus } from "./extension-contributor-context"; import { REQUIRED_INSTALLATION_PERMISSIONS } from "../github/backfill"; import type { GittensoryFooterEnv } from "../github/footer"; import { GITTENSORY_GATE_CHECK_NAME, shouldPublishReviewCheck } from "../review/check-names"; @@ -243,6 +245,9 @@ export type RepoSettingsPreview = { previewComment: string | null; appliedLabel: string | null; checkRun: { willCreate: boolean; title: string; detailLevel: RepositorySettings["checkRunDetailLevel"] } | null; + /** Public-safe readiness bands for the Context check details page (#2216). Null when check runs are off, + * detail level is minimal, or the sample would not publish a check run. */ + checkRunReadiness: Pick | null; installPreview: RepoInstallPreview; warnings: string[]; summary: string; @@ -359,6 +364,16 @@ export function buildRepoSettingsPreview(args: { previewComment, appliedLabel: decision.willLabel ? settings.gittensorLabel : null, checkRun: decision.willCheckRun ? { willCreate: true, title: "Gittensory Context", detailLevel: settings.checkRunDetailLevel } : null, + checkRunReadiness: buildSampleCheckRunReadiness({ + repoFullName, + repo, + settings, + issues: args.issues, + pullRequests: args.pullRequests, + sample, + body: args.sample.body ?? null, + decision, + }), installPreview, warnings, summary: decision.skipped @@ -586,6 +601,50 @@ function publicOutputSummary(decision: PublicSurfaceDecision): string { return decision.actions.includes("none") ? "The sample qualifies, but no public output action is enabled." : `Current sample would create: ${decision.actions.join(", ")}.`; } +/** Build the public-safe readiness table payload for the Context check details page (#2216). */ +export function buildSampleCheckRunReadiness(args: { + repoFullName: string; + repo: RepositoryRecord | null; + settings: RepositorySettings; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + sample: { authorLogin: string; authorAssociation: string; minerStatus: "confirmed" | "not_found" | "unavailable"; title: string; labels: string[]; linkedIssues: number[] }; + body: string | null; + decision: PublicSurfaceDecision; +}): Pick | null { + if (!args.decision.willCheckRun || args.settings.checkRunDetailLevel === "minimal") return null; + const samplePr: PullRequestRecord = { + repoFullName: args.repoFullName, + number: 0, + title: args.sample.title, + state: "open", + authorLogin: args.sample.authorLogin, + authorAssociation: args.sample.authorAssociation, + labels: args.sample.labels, + linkedIssues: args.sample.linkedIssues, + body: args.body, + }; + const collisions = buildCollisionReport(args.repoFullName, args.issues, args.pullRequests); + const queueHealth = buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); + const preflight = buildPreflightResult( + { + repoFullName: args.repoFullName, + contributorLogin: args.sample.authorLogin, + title: args.sample.title, + body: args.body ?? undefined, + labels: args.sample.labels, + linkedIssues: args.sample.linkedIssues, + authorAssociation: args.sample.authorAssociation, + }, + args.repo, + args.issues, + args.pullRequests, + ); + const readiness = buildPublicReadinessScore({ pr: samplePr, preflight, queueHealth }); + const status = buildExtensionPrStatus({ repoFullName: args.repoFullName, pullNumber: 0, readiness }); + return { readinessBand: status.readinessBand, components: status.components }; +} + function buildSamplePreviewComment(args: { repoFullName: string; repo: RepositoryRecord | null; diff --git a/test/unit/settings-preview.test.ts b/test/unit/settings-preview.test.ts index 4e14fd177d..f855466346 100644 --- a/test/unit/settings-preview.test.ts +++ b/test/unit/settings-preview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildRepoSettingsPreview, decidePublicSurface, type InstallationHealthSummary } from "../../src/signals/settings-preview"; +import { buildRepoSettingsPreview, buildSampleCheckRunReadiness, decidePublicSurface, type InstallationHealthSummary } from "../../src/signals/settings-preview"; import { REQUIRED_INSTALLATION_PERMISSIONS } from "../../src/github/backfill"; import { RepoSettingsPreviewSchema } from "../../src/openapi/schemas"; import type { IssueRecord, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; @@ -254,6 +254,76 @@ describe("buildRepoSettingsPreview", () => { expect(withoutChecks.warnings.some((warning) => /Checks: write/.test(warning))).toBe(false); }); + it("REGRESSION (#2216): omits checkRunReadiness at minimal detail level and includes public-safe bands at standard", () => { + const minimal = buildRepoSettingsPreview({env: {}, + ...base, + settings: settings({ checkRunMode: "enabled", checkRunDetailLevel: "minimal" }), + installation: healthyInstall, + sample: { authorLogin: "miner", minerStatus: "confirmed" }, + }); + expect(minimal.checkRun).toMatchObject({ willCreate: true, detailLevel: "minimal" }); + expect(minimal.checkRunReadiness).toBeNull(); + + const standard = buildRepoSettingsPreview({env: {}, + ...base, + settings: settings({ checkRunMode: "enabled", checkRunDetailLevel: "standard" }), + installation: healthyInstall, + sample: { authorLogin: "miner", minerStatus: "confirmed" }, + }); + expect(standard.checkRunReadiness).toMatchObject({ + readinessBand: expect.stringMatching(/^(strong|developing|early)$/), + components: expect.arrayContaining([ + expect.objectContaining({ + key: expect.any(String), + label: expect.any(String), + band: expect.stringMatching(/^(met|partial|unmet)$/), + evidence: expect.any(String), + action: expect.any(String), + }), + ]), + }); + expect(JSON.stringify(standard.checkRunReadiness)).not.toMatch(FORBIDDEN_INSTALL_PREVIEW_PUBLIC_LANGUAGE); + }); + + it("buildSampleCheckRunReadiness returns null when the surface decision will not publish a check run", () => { + const skipped = buildSampleCheckRunReadiness({ + repoFullName: repo.fullName, + repo, + settings: settings({ checkRunMode: "off", checkRunDetailLevel: "standard" }), + issues, + pullRequests, + sample: { authorLogin: "miner", authorAssociation: "CONTRIBUTOR", minerStatus: "confirmed", title: "Sample", labels: [], linkedIssues: [] }, + body: null, + decision: decidePublicSurface({ settings: settings({ checkRunMode: "off" }), authorLogin: "miner", minerStatus: "confirmed" }), + }); + expect(skipped).toBeNull(); + }); + + it("buildSampleCheckRunReadiness stays available with gate enabled or disabled (both gate branches)", () => { + const gateOff = buildSampleCheckRunReadiness({ + repoFullName: repo.fullName, + repo, + settings: settings({ checkRunMode: "enabled", checkRunDetailLevel: "standard", gateCheckMode: "off" }), + issues, + pullRequests, + sample: { authorLogin: "miner", authorAssociation: "CONTRIBUTOR", minerStatus: "confirmed", title: "Sample", labels: [], linkedIssues: [] }, + body: null, + decision: decidePublicSurface({ settings: settings({ checkRunMode: "enabled" }), authorLogin: "miner", minerStatus: "confirmed" }), + }); + const gateOn = buildSampleCheckRunReadiness({ + repoFullName: repo.fullName, + repo, + settings: settings({ checkRunMode: "enabled", checkRunDetailLevel: "standard", gateCheckMode: "enabled" }), + issues, + pullRequests, + sample: { authorLogin: "miner", authorAssociation: "CONTRIBUTOR", minerStatus: "confirmed", title: "Sample", labels: [], linkedIssues: [] }, + body: null, + decision: decidePublicSurface({ settings: settings({ checkRunMode: "enabled", gateCheckMode: "enabled" }), authorLogin: "miner", minerStatus: "confirmed" }), + }); + expect(gateOff?.components.length).toBeGreaterThan(0); + expect(gateOn?.components.length).toBeGreaterThan(0); + }); + it("requires Issues: write for detected-contributors comment mode even when previewing a non-confirmed sample", () => { // detected_contributors_only + comment_only comments for confirmed miners, so the repo needs // issues:write regardless of the previewed sample's miner status. Previewing a non-confirmed