diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index f2750e4c5b..6007993495 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7328,6 +7328,269 @@ "summary" ] }, + "CommandPreviewResponse": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "command": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "command": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "boundary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "id", + "command", + "audience", + "boundary", + "description", + "endpoint" + ] + }, + "request": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "preview": { + "type": "object", + "properties": { + "boundary": { + "type": "string", + "enum": [ + "public", + "public-safe", + "private-api", + "private-mcp" + ] + }, + "endpoint": { + "type": "string" + }, + "target": { + "type": "string" + }, + "body": { + "type": "string" + }, + "missingPermissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissionDiagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "requiredAccess": { + "type": "string" + }, + "currentAccess": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + } + }, + "required": [ + "permission", + "requiredAccess", + "currentAccess", + "ok", + "action" + ] + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "decision": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ready", + "skipped", + "missing_permission", + "private_api" + ] + }, + "willComment": { + "type": "boolean" + }, + "willLabel": { + "type": "boolean" + }, + "willCheckRun": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "skipReason": { + "type": "string", + "nullable": true + }, + "actions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "comment", + "label", + "check_run", + "skip", + "none" + ] + } + }, + "summary": { + "type": "string" + } + }, + "required": [ + "status", + "willComment", + "willLabel", + "willCheckRun", + "skipped", + "skipReason", + "actions", + "summary" + ] + }, + "sample": { + "type": "object", + "properties": { + "pullNumber": { + "type": "number" + }, + "authorLogin": { + "type": "string" + }, + "authorType": { + "type": "string" + }, + "authorAssociation": { + "type": "string" + }, + "commenterLogin": { + "type": "string" + }, + "commenterAssociation": { + "type": "string" + }, + "minerStatus": { + "type": "string", + "enum": [ + "confirmed", + "not_found", + "unavailable" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "linkedIssues": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "pullNumber", + "authorLogin", + "authorType", + "authorAssociation", + "commenterLogin", + "commenterAssociation", + "minerStatus", + "title", + "body", + "labels", + "linkedIssues" + ] + }, + "sanitizer": { + "type": "object", + "properties": { + "passed": { + "type": "boolean" + }, + "forbiddenTerms": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "passed", + "forbiddenTerms" + ] + } + }, + "required": [ + "boundary", + "endpoint", + "target", + "body", + "missingPermissions", + "permissionDiagnostics", + "warnings", + "decision" + ] + } + }, + "required": [ + "generatedAt", + "command", + "request", + "preview" + ] + }, "AgentRun": { "type": "object", "properties": { @@ -11649,27 +11912,11 @@ "post": { "responses": { "200": { - "description": "Live app mutation or preview response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "201": { - "description": "Created", + "description": "Maintainer dry-run preview of a sanitized @gittensory command response (no GitHub mutation)", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/CommandPreviewResponse" } } } @@ -11679,6 +11926,12 @@ }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role" + }, + "404": { + "description": "Command not found" } }, "security": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 704afdb33a..bc317a5c51 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -95,9 +95,16 @@ import { } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; -import { GITTENSORY_MENTION_COMMAND_CATALOG } from "../github/commands"; +import { + buildPublicAgentCommandComment, + buildMaintainerQueueDigest, + GITTENSORY_MENTION_COMMAND_CATALOG, + isAuthorizedCommandActor, + isMaintainerOnlyCommand, + sanitizePublicComment, + type GittensoryMentionCommandName, +} from "../github/commands"; import { handleGitHubWebhook } from "../github/webhook"; -import { sanitizePublicComment } from "../github/commands"; import { handleMcpRequest } from "../mcp/server"; import { buildOpenApiSpec } from "../openapi/spec"; import { generateSignalSnapshots } from "../queue/processors"; @@ -171,6 +178,7 @@ import type { JsonValue, ProductUsageOutcome, ProductUsageSurface, + PullRequestRecord, RepoSyncSegmentRecord, RepositoryRecord, } from "../types"; @@ -414,6 +422,23 @@ const commandPreviewSchema = z repoFullName: z.string().min(3).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), pullNumber: z.number().int().positive().optional(), login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + sample: z + .object({ + authorLogin: z.string().trim().min(1).max(100).optional(), + authorType: z.enum(["User", "Bot"]).optional(), + authorAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + commenterLogin: z.string().trim().min(1).max(100).optional(), + commenterAssociation: z.enum(["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "MANNEQUIN", "NONE"]).optional(), + minerStatus: z.enum(["confirmed", "not_found", "unavailable"]).optional(), + title: z.string().max(300).optional(), + body: z.string().max(10000).nullable().optional(), + labels: z.array(z.string().max(100)).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(50).optional(), + permissions: z.record(z.string(), z.string()).optional(), + missingPermissions: z.array(z.string().max(100)).max(50).optional(), + }) + .strict() + .optional(), }) .strict(); @@ -959,13 +984,23 @@ export function createApp() { ); app.post("/v1/app/commands/preview", async (c) => { + const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); + if (forbidden) return forbidden; const body = await c.req.json().catch(() => null); const parsed = commandPreviewSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_command_preview_request", issues: parsed.error.issues }, 400); const command = APP_COMMANDS.find((candidate) => candidate.command === parsed.data.command || candidate.id === parsed.data.command.replace(/^@gittensory\s+/, "")); if (!command) return c.json({ error: "command_not_found" }, 404); - const identity = await authenticateRequestIdentity(c).catch(() => null); - const preview = buildCommandPreview(command, parsed.data); + const identity = await authenticateRequestIdentity(c); + const [repo, pullRequest] = await Promise.all([ + parsed.data.repoFullName ? getRepository(c.env, parsed.data.repoFullName) : Promise.resolve(null), + parsed.data.repoFullName && parsed.data.pullNumber ? getPullRequest(c.env, parsed.data.repoFullName, parsed.data.pullNumber) : Promise.resolve(null), + ]); + const repoForbidden = await requireCommandPreviewRepoAccess(c, identity, parsed.data.repoFullName, repo); + if (repoForbidden) return repoForbidden; + const installationId = repo?.installationId ?? null; + const installation = installationId !== null ? await getInstallationHealth(c.env, installationId) : null; + const preview = buildCommandPreview(command, parsed.data, { repo, installation, pullRequest }); await recordRouteProductUsage(c, { surface: "control_panel", eventName: "command_previewed", @@ -2144,7 +2179,7 @@ const APP_COMMANDS = [ endpoint: "/v1/app/commands/preview", }, ...GITTENSORY_MENTION_COMMAND_CATALOG.filter( - (command) => !["help", "preflight", "blockers", "packet", "queue-summary", "review-now", "needs-author", "confirmed-miners", "duplicate-clusters"].includes(command.id), + (command) => !["preflight", "blockers", "packet", "queue-summary", "review-now", "needs-author", "confirmed-miners", "duplicate-clusters"].includes(command.id), ).map((command) => ({ id: command.id, command: `@gittensory ${command.id}`, @@ -2271,18 +2306,324 @@ function buildMaintainerSettingsPreview() { }; } -function buildCommandPreview(command: (typeof APP_COMMANDS)[number], request: z.infer) { +const PREVIEWABLE_MENTION_COMMANDS = new Set(GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => command.id)); + +type CommandPreviewDecision = { + status: "ready" | "skipped" | "missing_permission" | "private_api"; + willComment: boolean; + willLabel: boolean; + willCheckRun: boolean; + skipped: boolean; + skipReason: string | null; + actions: Array<"comment" | "label" | "check_run" | "skip" | "none">; + summary: string; +}; + +function buildCommandPreview( + command: (typeof APP_COMMANDS)[number], + request: z.infer, + context: { repo: RepositoryRecord | null; installation: InstallationHealthRecord | null; pullRequest: PullRequestRecord | null }, +) { const target = request.repoFullName ? `${request.repoFullName}${request.pullNumber ? `#${request.pullNumber}` : ""}` : "selected target"; - if (command.id === "public-summary") { + const mentionCommandName = previewableMentionCommandName(command.id); + if (!mentionCommandName) { + return buildPrivateApiCommandPreview(command, request, target); + } + + const sample = buildCommandPreviewSample(request, context.pullRequest); + const missingPermissions = commandPreviewMissingPermissions(request, context.installation); + const permissionWarnings = commandPreviewPermissionWarnings(missingPermissions); + const officialAuthorDetection = + sample.minerStatus === "confirmed" + ? { status: "confirmed" as const, snapshot: sampleMinerSnapshot(sample.authorLogin) } + : sample.minerStatus === "unavailable" + ? { status: "unavailable" as const, error: "Official miner detection is unavailable in this preview scenario." } + : { status: "not_found" as const }; + const authorization = isAuthorizedCommandActor({ + commandName: mentionCommandName, + commenterLogin: sample.commenterLogin, + commenterAssociation: sample.commenterAssociation, + pullRequestAuthorLogin: sample.authorLogin, + officialAuthorDetection, + }); + + const base = { + boundary: "public" as const, + endpoint: "GitHub issue comment", + target, + sample, + missingPermissions, + permissionDiagnostics: permissionWarnings.map((warning) => ({ + permission: warning.permission, + requiredAccess: warning.requiredAccess, + currentAccess: warning.currentAccess, + ok: false, + action: warning.action, + })), + warnings: permissionWarnings.map((warning) => warning.message), + }; + + if (!request.repoFullName || !request.pullNumber) { + const summary = commandPreviewSkipSummary("missing_target"); + const body = sanitizePublicComment(`Gittensory would not post a public command response for ${target}: ${summary}`); + return { + ...base, + body, + sanitizer: commandPreviewSanitizer(body), + decision: commandPreviewDecision({ + status: "skipped", + willComment: false, + skipReason: "missing_target", + summary, + }), + }; + } + + if (!authorization.authorized) { + const body = sanitizePublicComment(`Gittensory would not post a public command response for ${target}: ${commandPreviewSkipSummary(authorization.reason)}.`); + return { + ...base, + body, + sanitizer: commandPreviewSanitizer(body), + decision: commandPreviewDecision({ + status: "skipped", + willComment: false, + skipReason: authorization.reason, + summary: commandPreviewSkipSummary(authorization.reason), + }), + }; + } + + if (missingPermissions.includes("issues")) { + const summary = "GitHub App permission Issues: write is required before a command response can be posted."; + const body = sanitizePublicComment(`Gittensory preview is ready for ${target}, but ${summary}`); return { - boundary: "public", - body: `Gittensory can summarize public-safe context for ${target}. Private scorer details stay out of the PR thread.`, + ...base, + body, + sanitizer: commandPreviewSanitizer(body), + decision: commandPreviewDecision({ + status: "missing_permission", + willComment: false, + skipReason: "missing_permission", + summary, + }), }; } + + const issue = { + number: sample.pullNumber, + title: sample.title, + state: "open", + ...(request.repoFullName && request.pullNumber ? { html_url: `https://github.com/${request.repoFullName}/pull/${request.pullNumber}` } : {}), + user: { login: sample.authorLogin }, + author_association: sample.authorAssociation, + labels: sample.labels.map((name) => ({ name })), + body: sample.body, + pull_request: {}, + }; + const pullRequest = buildCommandPreviewPullRequest(request, sample, context.pullRequest); + const body = + command.id === "public-summary" + ? `Gittensory can summarize public-safe context for ${target}. Private scorer details stay out of the PR thread.` + : buildPublicAgentCommandComment({ + command: { name: mentionCommandName, raw: `@gittensory ${mentionCommandName}` }, + repo: context.repo, + issue, + pullRequest, + actorKind: authorization.actorKind === "maintainer" ? "maintainer" : "author", + officialMiner: officialAuthorDetection.status === "confirmed" ? officialAuthorDetection.snapshot : null, + maintainerDigest: isMaintainerOnlyCommand(mentionCommandName) + ? buildMaintainerQueueDigest({ + repo: context.repo, + issues: [], + pullRequests: [pullRequest], + confirmedMinerLogins: sample.minerStatus === "confirmed" ? [sample.authorLogin] : [], + }) + : null, + }); + + return { + ...base, + body, + sanitizer: commandPreviewSanitizer(body), + decision: commandPreviewDecision({ + status: "ready", + willComment: true, + skipReason: null, + summary: "Gittensory would post this sanitized command response and would not create labels or check runs.", + }), + }; +} + +function buildPrivateApiCommandPreview(command: (typeof APP_COMMANDS)[number], request: z.infer, target: string) { return { boundary: command.boundary, endpoint: command.endpoint, + target, body: `${command.command} will call ${command.endpoint} for ${target}${request.login ? ` as ${request.login}` : ""}.`, + missingPermissions: [], + permissionDiagnostics: [], + warnings: [], + decision: commandPreviewDecision({ + status: "private_api", + willComment: false, + skipReason: null, + summary: "Private API preview only; no GitHub comment, label, or check run would be created.", + }), + }; +} + +function previewableMentionCommandName(commandId: string): GittensoryMentionCommandName | null { + if (PREVIEWABLE_MENTION_COMMANDS.has(commandId as GittensoryMentionCommandName)) return commandId as GittensoryMentionCommandName; + if (commandId === "public-summary") return "help"; + return null; +} + +function commandPreviewDecision(args: { + status: CommandPreviewDecision["status"]; + willComment: boolean; + skipReason: string | null; + summary: string; +}): CommandPreviewDecision { + return { + status: args.status, + willComment: args.willComment, + willLabel: false, + willCheckRun: false, + skipped: args.status === "skipped" || args.status === "missing_permission", + skipReason: args.skipReason, + actions: args.willComment ? ["comment"] : args.status === "private_api" ? ["none"] : ["skip"], + summary: args.summary, + }; +} + +function buildCommandPreviewSample(request: z.infer, pullRequest: PullRequestRecord | null) { + const sample = request.sample ?? {}; + const authorLogin = sample.authorLogin?.trim() || pullRequest?.authorLogin || request.login || "sample-contributor"; + const commenterAssociation = + sample.commenterAssociation ?? (isMaintainerOnlyCommand(previewableMentionCommandName(request.command.replace(/^@gittensory\s+/, "")) ?? "help") ? "OWNER" : "NONE"); + return { + pullNumber: request.pullNumber ?? pullRequest?.number ?? 1, + authorLogin, + authorType: sample.authorType ?? "User", + authorAssociation: sample.authorAssociation ?? pullRequest?.authorAssociation ?? "NONE", + commenterLogin: sample.commenterLogin?.trim() || request.login || authorLogin, + commenterAssociation, + minerStatus: sample.minerStatus ?? "confirmed", + title: sample.title?.trim() || pullRequest?.title || "Sample pull request", + body: sample.body ?? pullRequest?.body ?? null, + labels: sample.labels ?? pullRequest?.labels ?? [], + linkedIssues: sample.linkedIssues ?? pullRequest?.linkedIssues ?? [], + }; +} + +function buildCommandPreviewPullRequest( + request: z.infer, + sample: ReturnType, + pullRequest: PullRequestRecord | null, +): PullRequestRecord { + return { + repoFullName: request.repoFullName ?? pullRequest?.repoFullName ?? "selected/repository", + number: sample.pullNumber, + title: sample.title, + state: pullRequest?.state ?? "open", + authorLogin: sample.authorLogin, + authorAssociation: sample.authorAssociation, + headSha: pullRequest?.headSha ?? "preview-head-sha", + headRef: pullRequest?.headRef ?? "preview-branch", + baseRef: pullRequest?.baseRef ?? "main", + htmlUrl: pullRequest?.htmlUrl ?? (request.repoFullName && sample.pullNumber ? `https://github.com/${request.repoFullName}/pull/${sample.pullNumber}` : null), + mergedAt: null, + isDraft: pullRequest?.isDraft ?? false, + mergeableState: pullRequest?.mergeableState ?? null, + reviewDecision: pullRequest?.reviewDecision ?? null, + body: sample.body, + createdAt: pullRequest?.createdAt ?? nowIso(), + updatedAt: pullRequest?.updatedAt ?? nowIso(), + labels: sample.labels, + linkedIssues: sample.linkedIssues, + }; +} + +function commandPreviewMissingPermissions(request: z.infer, installation: InstallationHealthRecord | null): string[] { + const configured = new Set([...(installation?.missingPermissions ?? []), ...(request.sample?.missingPermissions ?? [])]); + const permissions = request.sample?.permissions ?? installation?.permissions; + if (permissions && permissions.issues !== "write") configured.add("issues"); + return [...configured].sort(); +} + +function commandPreviewPermissionWarnings(missingPermissions: string[]) { + return missingPermissions.map((permission) => { + const requiredAccess = "write"; + const currentAccess = "missing"; + return { + permission, + requiredAccess, + currentAccess, + action: `Set repository permission ${permission} to ${requiredAccess}, then approve the GitHub App permission change.`, + message: + permission === "issues" + ? "Command responses require GitHub App permission Issues: write; preview will not post while it is missing." + : `GitHub App permission ${permission}: ${requiredAccess} is missing for this preview scenario.`, + }; + }); +} + +function commandPreviewSanitizer(body: string) { + const forbiddenTerms = [ + "wallet", + "hotkey", + "raw trust", + "trust score", + "payout", + "reward estimate", + "farming", + "scoreability", + "public score estimate", + ].filter((term) => new RegExp(term, "i").test(body)); + return { passed: forbiddenTerms.length === 0, forbiddenTerms }; +} + +function commandPreviewSkipSummary(reason: string): string { + const summaries: Record = { + missing_target: "public command previews require a repository and pull request number.", + maintainer_command_requires_maintainer: "maintainer-only commands require an owner, member, or collaborator invocation.", + not_maintainer_or_pr_author: "the commenter is neither a maintainer nor the pull request author.", + miner_detection_unavailable: "official Gittensor miner detection is unavailable, so Gittensory would skip rather than guess.", + pr_author_not_confirmed_miner: "the pull request author is not a confirmed Gittensor miner.", + }; + return summaries[reason] ?? reason.replace(/_/g, " "); +} + +function sampleMinerSnapshot(login: string) { + return { + source: "gittensor_api" as const, + githubId: `preview-${login}`, + githubUsername: login, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 1, + mergedPullRequests: 0, + openPullRequests: 1, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [], + issueLabels: [], }; } @@ -2684,6 +3025,26 @@ async function requireContributorAccess(c: ProtectedRouteContext, login: string) return null; } +async function requireCommandPreviewRepoAccess( + c: ProtectedRouteContext, + identity: AuthIdentity | null, + repoFullName: string | undefined, + repo: RepositoryRecord | null, +): Promise { + /* v8 ignore next -- The broad route role guard already authenticates protected preview requests. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + if (identity.kind !== "session" || !repoFullName) return null; + const summary = await loadControlPanelRoleSummary(c.env, identity.actor); + if (summary.roles.includes("operator")) return null; + const scope = await loadControlPanelAccessScope(c.env, identity.actor); + const requestedRepo = repoFullName.toLowerCase(); + const scopedRepoNames = new Set(scope.repositoryFullNames.map((name) => name.toLowerCase())); + if (scopedRepoNames.has(requestedRepo)) return null; + if (repo?.installationId !== undefined && repo.installationId !== null && scope.installationIds.includes(repo.installationId)) return null; + if (repo && scope.accountLogins.some((login) => login.toLowerCase() === repo.owner.toLowerCase())) return null; + return c.json({ error: "forbidden_repo" }, 403); +} + function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/v1/mcp/compatibility") return false; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 9eb52acb32..27b66c2c43 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -598,6 +598,69 @@ export const RepoSettingsPreviewSchema = z }) .openapi("RepoSettingsPreview"); +export const CommandPreviewResponseSchema = z + .object({ + generatedAt: z.string(), + command: z.object({ + id: z.string(), + command: z.string(), + audience: z.string(), + boundary: z.string(), + description: z.string(), + endpoint: z.string(), + }), + request: z.record(z.string(), z.unknown()), + preview: z.object({ + boundary: z.enum(["public", "public-safe", "private-api", "private-mcp"]), + endpoint: z.string(), + target: z.string(), + body: z.string(), + missingPermissions: z.array(z.string()), + permissionDiagnostics: z.array( + z.object({ + permission: z.string(), + requiredAccess: z.string(), + currentAccess: z.string(), + ok: z.boolean(), + action: z.string(), + }), + ), + warnings: z.array(z.string()), + decision: z.object({ + status: z.enum(["ready", "skipped", "missing_permission", "private_api"]), + willComment: z.boolean(), + willLabel: z.boolean(), + willCheckRun: z.boolean(), + skipped: z.boolean(), + skipReason: z.string().nullable(), + actions: z.array(z.enum(["comment", "label", "check_run", "skip", "none"])), + summary: z.string(), + }), + sample: z + .object({ + pullNumber: z.number(), + authorLogin: z.string(), + authorType: z.string(), + authorAssociation: z.string(), + commenterLogin: z.string(), + commenterAssociation: z.string(), + minerStatus: z.enum(["confirmed", "not_found", "unavailable"]), + title: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + linkedIssues: z.array(z.number()), + }) + .optional(), + sanitizer: z + .object({ + passed: z.boolean(), + forbiddenTerms: z.array(z.string()), + }) + .optional(), + }), + }) + .openapi("CommandPreviewResponse"); + export const RepoSyncStateSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 5343b06f13..8badc687c5 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -12,6 +12,7 @@ import { BurdenForecastSchema, CollisionReportSchema, ConfigQualitySchema, + CommandPreviewResponseSchema, ContributorFitSchema, ContributorIntakeHealthSchema, ContributorOutcomeHistorySchema, @@ -114,6 +115,7 @@ export function buildOpenApiSpec() { registry.register("BountyLifecycleEvents", BountyLifecycleEventsSchema); registry.register("RepositorySettings", RepositorySettingsSchema); registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema); + registry.register("CommandPreviewResponse", CommandPreviewResponseSchema); registry.register("AgentRun", AgentRunSchema); registry.register("AgentAction", AgentActionSchema); registry.register("AgentContextSnapshot", AgentContextSnapshotSchema); @@ -594,7 +596,18 @@ export function buildOpenApiSpec() { }, }); } - for (const path of ["/v1/app/commands/preview", "/v1/app/commands/feedback", "/v1/app/digest/subscriptions"]) { + registry.registerPath({ + method: "post", + path: "/v1/app/commands/preview", + responses: { + 200: { description: "Maintainer dry-run preview of a sanitized @gittensory command response (no GitHub mutation)", content: { "application/json": { schema: CommandPreviewResponseSchema } } }, + 400: { description: "Invalid request" }, + 401: { description: "Unauthorized" }, + 403: { description: "Insufficient app role" }, + 404: { description: "Command not found" }, + }, + }); + for (const path of ["/v1/app/commands/feedback", "/v1/app/digest/subscriptions"]) { registry.registerPath({ method: "post", path, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 2bbf76271c..b833fde21f 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1199,6 +1199,7 @@ describe("api routes", () => { roleCards: expect.arrayContaining([expect.objectContaining({ role: "operator", status: "active" })]), publicSafe: true, }); + expect((await app.request("/v1/app/roles", {}, env)).status).toBe(401); const emptyEnv = createTestEnv(); const emptyOverview = await app.request("/v1/app/overview", { headers: apiHeaders(emptyEnv) }, emptyEnv); @@ -1239,6 +1240,7 @@ describe("api routes", () => { const { token: otherToken } = await createSessionForGitHubUser(env, { login: "other", id: 987 }); const forbiddenMiner = await app.request("/v1/app/miner-dashboard?login=oktofeesh1", { headers: { cookie: `gittensory_session=${otherToken}` } }, env); expect(forbiddenMiner.status).toBe(403); + expect((await app.request("/v1/app/maintainer-dashboard", {}, env)).status).toBe(401); const unknownEnv = createTestEnv({ ADMIN_GITHUB_LOGINS: "jsonbored" }); const { token: unknownToken } = await createSessionForGitHubUser(unknownEnv, { login: "new-user", id: 2468 }); @@ -1257,6 +1259,7 @@ describe("api routes", () => { expect(unknownOverview.status).toBe(200); await expect(unknownOverview.json()).resolves.toMatchObject({ roleSummary: { roles: [], onboarding: { status: "needs_setup" } } }); expect((await app.request("/v1/app/operator-dashboard", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); + expect((await app.request("/v1/app/maintainer-dashboard", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/app/commands/usefulness", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect( ( @@ -1362,6 +1365,48 @@ describe("api routes", () => { ownerEnv, ); expect(ownerExtensionMissingPull.status).toBe(400); + const ownerRepoPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ command: "plan-next-work", repoFullName: "repo-owner/owned-repo" }), + }, + ownerEnv, + ); + expect(ownerRepoPreview.status).toBe(200); + const ownerGenericPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ command: "plan-next-work" }), + }, + ownerEnv, + ); + expect(ownerGenericPreview.status).toBe(200); + const forbiddenVictimPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: ownerHeaders, + body: JSON.stringify({ command: "plan-next-work", repoFullName: "victim-org/secret-repo" }), + }, + ownerEnv, + ); + expect(forbiddenVictimPreview.status).toBe(403); + await expect(forbiddenVictimPreview.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + const { token: operatorToken } = await createSessionForGitHubUser(ownerEnv, { login: "jsonbored", id: 1 }); + const operatorVictimPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: { cookie: `gittensory_session=${operatorToken}`, "content-type": "application/json" }, + body: JSON.stringify({ command: "plan-next-work", repoFullName: "victim-org/secret-repo" }), + }, + ownerEnv, + ); + expect(operatorVictimPreview.status).toBe(200); const minerNeedsRefresh = await app.request("/v1/app/miner-dashboard?login=oktofeesh1", { headers: apiHeaders(env) }, env); expect(minerNeedsRefresh.status).toBe(200); @@ -1644,7 +1689,7 @@ describe("api routes", () => { const commands = await app.request("/v1/app/commands", { headers: apiHeaders(env) }, env); expect(commands.status).toBe(200); await expect(commands.json()).resolves.toMatchObject({ - commands: expect.arrayContaining([expect.objectContaining({ id: "public-summary" })]), + commands: expect.arrayContaining([expect.objectContaining({ id: "help" }), expect.objectContaining({ id: "public-summary" })]), }); const publicPreview = await app.request( @@ -1657,22 +1702,168 @@ describe("api routes", () => { env, ); expect(publicPreview.status).toBe(200); - await expect(publicPreview.json()).resolves.toMatchObject({ - preview: { boundary: "public", body: expect.stringContaining("entrius/allways-ui#12") }, + const publicPreviewBody = await publicPreview.json(); + expect(publicPreviewBody).toMatchObject({ + preview: { boundary: "public", body: expect.stringContaining("entrius/allways-ui#12"), decision: { status: "ready", willComment: true, willLabel: false, willCheckRun: false } }, + }); + + const commandResponsePreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", pullNumber: 12, login: "oktofeesh1" }), + }, + env, + ); + expect(commandResponsePreview.status).toBe(200); + const commandResponsePreviewBody = (await commandResponsePreview.json()) as { preview: { body: string } }; + expect(commandResponsePreviewBody).toMatchObject({ + preview: { + boundary: "public", + endpoint: "GitHub issue comment", + decision: { status: "ready", willComment: true, willLabel: false, willCheckRun: false }, + sanitizer: { passed: true, forbiddenTerms: [] }, + body: expect.stringContaining("### Gittensory preflight"), + }, }); + expect(commandResponsePreviewBody.preview.body).toContain("Scope: entrius/allways-ui#12"); + expect(commandResponsePreviewBody.preview.body).not.toMatch(/wallet|hotkey|raw trust|payout|reward estimate|farming|scoreability|public score estimate/i); - const privatePreview = await app.request( + const nonMinerPreview = await app.request( "/v1/app/commands/preview", { method: "POST", headers: apiHeaders(env), - body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", login: "oktofeesh1" }), + body: JSON.stringify({ command: "miner-context", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { minerStatus: "not_found" } }), }, env, ); - expect(privatePreview.status).toBe(200); - await expect(privatePreview.json()).resolves.toMatchObject({ - preview: { boundary: "private-api", endpoint: "/v1/agent/preflight-branch" }, + expect(nonMinerPreview.status).toBe(200); + await expect(nonMinerPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "skipped", willComment: false, skipReason: "pr_author_not_confirmed_miner" }, body: expect.stringContaining("not a confirmed Gittensor miner") }, + }); + + const missingPermissionPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { missingPermissions: ["issues"] } }), + }, + env, + ); + expect(missingPermissionPreview.status).toBe(200); + await expect(missingPermissionPreview.json()).resolves.toMatchObject({ + preview: { + decision: { status: "missing_permission", willComment: false, skipReason: "missing_permission" }, + missingPermissions: ["issues"], + permissionDiagnostics: [expect.objectContaining({ permission: "issues", requiredAccess: "write", ok: false })], + warnings: [expect.stringMatching(/Issues: write/i)], + }, + }); + + const permissionMapPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { permissions: { metadata: "read", pull_requests: "read" } } }), + }, + env, + ); + expect(permissionMapPreview.status).toBe(200); + await expect(permissionMapPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "missing_permission", skipReason: "missing_permission" }, missingPermissions: ["issues"] }, + }); + + const checksWarningPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { missingPermissions: ["checks"] } }), + }, + env, + ); + expect(checksWarningPreview.status).toBe(200); + await expect(checksWarningPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "ready", willComment: true, willCheckRun: false }, missingPermissions: ["checks"], warnings: [expect.stringMatching(/checks: write/i)] }, + }); + + const unavailableMinerPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "miner-context", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { minerStatus: "unavailable" } }), + }, + env, + ); + expect(unavailableMinerPreview.status).toBe(200); + await expect(unavailableMinerPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "skipped", skipReason: "miner_detection_unavailable" }, body: expect.stringContaining("detection is unavailable") }, + }); + + const wrongActorPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "next-action", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { authorLogin: "sample-author", commenterLogin: "other-user" } }), + }, + env, + ); + expect(wrongActorPreview.status).toBe(200); + await expect(wrongActorPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "skipped", skipReason: "not_maintainer_or_pr_author" }, body: expect.stringContaining("neither a maintainer nor the pull request author") }, + }); + + const helpPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "help", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { authorLogin: "sample-author", commenterLogin: "sample-author" } }), + }, + env, + ); + expect(helpPreview.status).toBe(200); + await expect(helpPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "ready", willComment: true }, body: expect.stringContaining("### Gittensory command help") }, + }); + + const maintainerCommandPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + command: "queue-summary", + repoFullName: "entrius/allways-ui", + pullNumber: 12, + sample: { authorAssociation: "OWNER", minerStatus: "not_found" }, + }), + }, + env, + ); + expect(maintainerCommandPreview.status).toBe(200); + await expect(maintainerCommandPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "ready", willComment: true }, body: expect.stringContaining("### Gittensory maintainer queue summary") }, + }); + + const maintainerMinerContextPreview = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "miner-context", repoFullName: "entrius/allways-ui", pullNumber: 12, sample: { commenterAssociation: "OWNER", minerStatus: "not_found" } }), + }, + env, + ); + expect(maintainerMinerContextPreview.status).toBe(200); + await expect(maintainerMinerContextPreview.json()).resolves.toMatchObject({ + preview: { decision: { status: "ready", willComment: true }, body: expect.stringContaining("Official miner context is unavailable") }, }); const privatePreviewWithoutTarget = await app.request( @@ -1680,13 +1871,27 @@ describe("api routes", () => { { method: "POST", headers: apiHeaders(env), - body: JSON.stringify({ command: "preflight" }), + body: JSON.stringify({ command: "plan-next-work" }), }, env, ); expect(privatePreviewWithoutTarget.status).toBe(200); await expect(privatePreviewWithoutTarget.json()).resolves.toMatchObject({ - preview: { body: expect.stringContaining("selected target") }, + preview: { boundary: "private-api", body: expect.stringContaining("selected target"), decision: { status: "private_api", willComment: false } }, + }); + + const privatePreviewWithLogin = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "plan-next-work", repoFullName: "entrius/allways-ui", login: "oktofeesh1" }), + }, + env, + ); + expect(privatePreviewWithLogin.status).toBe(200); + await expect(privatePreviewWithLogin.json()).resolves.toMatchObject({ + preview: { target: "entrius/allways-ui", body: expect.stringContaining("as oktofeesh1") }, }); const previewWithoutRepo = await app.request( @@ -1699,6 +1904,37 @@ describe("api routes", () => { env, ); expect(previewWithoutRepo.status).toBe(200); + await expect(previewWithoutRepo.json()).resolves.toMatchObject({ + preview: { decision: { status: "skipped", willComment: false, skipReason: "missing_target" }, body: expect.stringContaining("require a repository and pull request number") }, + }); + + const publicPreviewWithoutTarget = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "help", sample: { authorLogin: "sample-author", commenterLogin: "sample-author" } }), + }, + env, + ); + expect(publicPreviewWithoutTarget.status).toBe(200); + await expect(publicPreviewWithoutTarget.json()).resolves.toMatchObject({ + preview: { decision: { status: "skipped", willComment: false, skipReason: "missing_target" }, body: expect.stringContaining("require a repository and pull request number") }, + }); + + const publicPreviewWithoutPull = await app.request( + "/v1/app/commands/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ command: "help", repoFullName: "entrius/allways-ui", sample: { authorLogin: "sample-author", commenterLogin: "sample-author" } }), + }, + env, + ); + expect(publicPreviewWithoutPull.status).toBe(200); + await expect(publicPreviewWithoutPull.json()).resolves.toMatchObject({ + preview: { target: "entrius/allways-ui", decision: { status: "skipped", willComment: false, skipReason: "missing_target" } }, + }); const telemetryDownPreviewEnv = withProductUsageInsertFailure(createTestEnv()); const telemetryDownPreview = await app.request( @@ -1943,6 +2179,77 @@ describe("api routes", () => { const invalidMissingOwnerExtensionContext = await app.request("/v1/extension/pull-context?repo=allways-ui&pullNumber=12", { headers: { authorization: `Bearer ${extensionSessionBody.token}` } }, env); expect(invalidMissingOwnerExtensionContext.status).toBe(400); + await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { + number: 14, + title: "Maintainer queue cleanup", + state: "open", + html_url: "https://github.com/entrius/allways-ui/pull/14", + user: { login: "repo-maintainer" }, + author_association: "OWNER", + head: { sha: "owner123", ref: "maintainer-cleanup" }, + base: { ref: "test" }, + labels: [{ name: "feature" }], + body: "Fixes #8", + }); + await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { + number: 15, + title: "Closed cleanup attempt", + state: "closed", + html_url: "https://github.com/entrius/allways-ui/pull/15", + user: { login: "outside-contributor" }, + author_association: "NONE", + head: { sha: "closed123", ref: "closed-cleanup" }, + base: { ref: "test" }, + labels: [{ name: "feature" }], + body: "Fixes #8", + }); + await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { + number: 16, + title: "Broad unlinked rewrite", + state: "open", + html_url: "https://github.com/entrius/allways-ui/pull/16", + user: { login: "outside-contributor" }, + author_association: "NONE", + head: { sha: "watch123", ref: "broad-rewrite" }, + base: { ref: "test" }, + labels: [{ name: "feature" }], + body: "Large rewrite without linked issue context.", + }); + await upsertPullRequestFile(env, { + repoFullName: "entrius/allways-ui", + pullNumber: 16, + path: "src/broad-rewrite.ts", + additions: 900, + deletions: 10, + changes: 910, + payload: {}, + }); + await upsertCheckSummary(env, { + id: "entrius/allways-ui#watch123#test", + repoFullName: "entrius/allways-ui", + pullNumber: 16, + headSha: "watch123", + name: "test", + status: "completed", + conclusion: "failure", + payload: {}, + }); + + for (const [pullNumber, expectedPacketText] of [ + [14, "Public status: maintainer follow-up recommended."], + [15, "Public status: triage may be needed before review."], + [16, "Public status: keep monitoring the public PR context."], + ] as const) { + const variantContext = await app.request( + `/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=${pullNumber}`, + { headers: { authorization: `Bearer ${extensionSessionBody.token}` } }, + env, + ); + expect(variantContext.status).toBe(200); + const variantPayload = (await variantContext.json()) as { actions: Array<{ id: string; markdown?: string }> }; + expect(variantPayload.actions.find((action) => action.id === "copy_public_safe_packet")?.markdown).toContain(expectedPacketText); + } + const extensionContext = await app.request( "/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=12", { headers: { authorization: `Bearer ${extensionSessionBody.token}` } }, @@ -2054,7 +2361,7 @@ describe("api routes", () => { occurredAt: "2026-05-28T00:00:00.000Z", }); - const productUsageEvents = await listProductUsageEvents(env, { limit: 20 }); + const productUsageEvents = await listProductUsageEvents(env, { limit: 40 }); expect(productUsageEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ surface: "control_panel", eventName: "command_previewed", outcome: "success" }), @@ -2264,6 +2571,12 @@ describe("api routes", () => { const invalidCommandPreview = await app.request("/v1/app/commands/preview", { method: "POST", headers: apiHeaders(env), body: "{" }, env); expect(invalidCommandPreview.status).toBe(400); + const invalidQueueJson = await app.request("/v1/internal/queue-intelligence", { method: "POST", headers: internalHeaders, body: "{" }, env); + expect(invalidQueueJson.status).toBe(400); + const invalidQueueShape = await app.request("/v1/internal/queue-intelligence", { method: "POST", headers: internalHeaders, body: "{}" }, env); + expect(invalidQueueShape.status).toBe(400); + await expect(invalidQueueShape.json()).resolves.toMatchObject({ error: "invalid_request", detail: "pullRequests array required" }); + const invalidDigestJson = await app.request("/v1/app/digest/subscriptions", { method: "POST", headers: { cookie: `gittensory_session=${noIdToken}` }, body: "{" }, env); expect(invalidDigestJson.status).toBe(400); @@ -2349,6 +2662,30 @@ describe("api routes", () => { expect(mutatingCalls).toEqual([]); }); + it("command response preview never mutates GitHub state", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedSignalData(env); + const calls: Array<{ method: string; url: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ method: (init?.method ?? "GET").toUpperCase(), url: input.toString() }); + return new Response("not found", { status: 404 }); + }); + const response = await app.request( + "/v1/app/commands/preview", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ command: "preflight", repoFullName: "entrius/allways-ui", pullNumber: 12, login: "oktofeesh1" }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + preview: { decision: { status: "ready", willComment: true, willLabel: false, willCheckRun: false } }, + }); + const githubCalls = calls.filter((call) => /github\.com/.test(call.url)); + expect(githubCalls).toEqual([]); + const mutatingCalls = calls.filter((call) => call.method !== "GET" && call.method !== "HEAD"); + expect(mutatingCalls).toEqual([]); + }); + it("returns 404 for unknown repos and serves cached snapshot with freshness for known repos", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/maintainer-settings-preview-ui.test.ts b/test/unit/maintainer-settings-preview-ui.test.ts index bd6e1e9bbf..9e024d054b 100644 --- a/test/unit/maintainer-settings-preview-ui.test.ts +++ b/test/unit/maintainer-settings-preview-ui.test.ts @@ -63,6 +63,31 @@ describe("maintainer settings preview UI helpers", () => { }); }); + it("falls back to the default scenario and omits blank optional body text", () => { + expect(findPreviewScenario("unknown-scenario" as never)).toEqual(findPreviewScenario("confirmed-miner")); + + expect( + buildSettingsPreviewRequest({ + repoFullName: "JSONbored/gittensory", + scenarioId: "confirmed-miner", + title: "Review cache preview", + labels: "", + linkedIssues: "", + body: " ", + }), + ).toEqual({ + sample: { + authorLogin: "sample-miner", + authorType: "User", + authorAssociation: "CONTRIBUTOR", + minerStatus: "confirmed", + title: "Review cache preview", + labels: [], + linkedIssues: [], + }, + }); + }); + it("keeps all required simulator scenarios available", () => { expect(findPreviewScenario("confirmed-miner").sample).toMatchObject({ minerStatus: "confirmed", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 57a2369aaf..b8d86ef496 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -93,6 +93,7 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); expect(spec.components?.schemas?.RepoSettingsPreview).toBeDefined(); + expect(spec.components?.schemas?.CommandPreviewResponse).toBeDefined(); expect(spec.components?.schemas?.AgentRunBundle).toBeDefined(); expect(spec.components?.schemas?.AgentAction).toBeDefined(); expect(spec.components?.schemas?.UpstreamStatus).toBeDefined(); @@ -100,6 +101,7 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.UpstreamDriftReport).toBeDefined(); expect(JSON.stringify(spec.components?.schemas?.ScorePreviewResult)).toContain("scenarioPreviews"); expect(JSON.stringify(spec.components?.schemas?.RepoIntelligence)).toContain("burdenForecastFreshness"); + expect(JSON.stringify(spec.components?.schemas?.CommandPreviewResponse)).toContain("missing_permission"); expect(JSON.stringify(spec.components?.schemas?.ContributorOutcomeHistory)).toContain("reconciliation"); expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("baseFreshness"); expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("recommendedRerunCondition");