diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 18c2ec3a3a..ea06c441ca 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -313,6 +313,167 @@ "isPrivate" ] }, + "PublicRepoStats": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "htmlUrl": { + "type": "string" + }, + "stargazers_count": { + "type": "number" + }, + "forks_count": { + "type": "number" + }, + "fetched_at": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "github", + "cache", + "stale_cache" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "repoFullName", + "htmlUrl", + "stargazers_count", + "forks_count", + "fetched_at", + "source", + "stale" + ] + }, + "PublicStats": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "totals": { + "type": "object", + "properties": { + "handled": { + "type": "number" + }, + "reviewed": { + "type": "number" + }, + "merged": { + "type": "number" + }, + "closed": { + "type": "number" + }, + "commented": { + "type": "number" + }, + "ignored": { + "type": "number" + }, + "manual": { + "type": "number" + }, + "error": { + "type": "number" + }, + "reversed": { + "type": "number" + }, + "filteredPct": { + "type": "number", + "nullable": true + }, + "accuracyPct": { + "type": "number", + "nullable": true + }, + "minutesSaved": { + "type": "number" + } + }, + "required": [ + "handled", + "reviewed", + "merged", + "closed", + "commented", + "ignored", + "manual", + "error", + "reversed", + "filteredPct", + "accuracyPct", + "minutesSaved" + ] + }, + "weekly": { + "type": "object", + "properties": { + "reviewed": { + "type": "number" + }, + "merged": { + "type": "number" + } + }, + "required": [ + "reviewed", + "merged" + ] + }, + "byProject": { + "type": "array", + "items": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "reviewed": { + "type": "number" + }, + "merged": { + "type": "number" + }, + "closed": { + "type": "number" + }, + "accuracyPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "project", + "reviewed", + "merged", + "closed", + "accuracyPct" + ] + } + } + }, + "required": [ + "generatedAt", + "updatedAt", + "totals", + "weekly", + "byProject" + ] + }, "Advisory": { "type": "object", "properties": { @@ -1434,18 +1595,6 @@ "unknown" ] }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } - }, "multiplierTier": { "type": "string", "enum": [ @@ -1459,6 +1608,18 @@ "ready", "maintainer_wip" ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2362,6 +2523,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2385,9 +2549,6 @@ }, "maintainerLaneTotal": { "type": "number" - }, - "rejected": { - "type": "number" } }, "required": [ @@ -2404,6 +2565,21 @@ "maintainerLaneTotal" ] }, + "sources": { + "type": "object", + "properties": { + "explicit": { + "type": "number" + }, + "inferred": { + "type": "number" + } + }, + "required": [ + "explicit", + "inferred" + ] + }, "states": { "type": "array", "items": { @@ -2436,21 +2612,6 @@ }, "privateSummary": { "type": "string" - }, - "sources": { - "type": "object", - "properties": { - "explicit": { - "type": "number" - }, - "inferred": { - "type": "number" - } - }, - "required": [ - "explicit", - "inferred" - ] } }, "required": [ @@ -2504,6 +2665,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2540,9 +2704,6 @@ "mixed", "neutral" ] - }, - "rejected": { - "type": "number" } }, "required": [ @@ -4255,22 +4416,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -4306,13 +4467,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -4564,22 +4725,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -4615,13 +4776,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -4873,22 +5034,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -4924,13 +5085,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -5182,22 +5343,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -5233,13 +5394,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -5491,22 +5652,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -5542,13 +5703,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -6379,22 +6540,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -6507,13 +6668,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -6764,22 +6925,22 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "openIssueMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "mergedHistoryMultiplier": { "type": "number" }, - "timeDecayMultiplier": { + "issueDiscoveryHistoryMultiplier": { "type": "number" }, - "openIssueMultiplier": { + "timeDecayMultiplier": { "type": "number" }, - "mergedHistoryMultiplier": { + "estimatedMergedScore": { "type": "number" }, - "issueDiscoveryHistoryMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -6815,13 +6976,13 @@ "collateralFraction": { "type": "number" }, - "credibilityFloor": { + "reviewCollateralMultiplier": { "type": "number" }, - "credibilityObserved": { + "credibilityFloor": { "type": "number" }, - "reviewCollateralMultiplier": { + "credibilityObserved": { "type": "number" }, "openIssueThreshold": { @@ -7311,6 +7472,15 @@ "repoFullName": { "type": "string" }, + "severity": { + "type": "string", + "enum": [ + "critical", + "warning", + "tip", + "info" + ] + }, "priorityScore": { "type": "number" }, @@ -7343,15 +7513,6 @@ "items": { "type": "string" } - }, - "severity": { - "type": "string", - "enum": [ - "critical", - "warning", - "tip", - "info" - ] } }, "required": [ @@ -8319,81 +8480,42 @@ "enabled" ] }, - "autoLabelEnabled": { - "type": "boolean" - }, - "gittensorLabel": { - "type": "string" - }, - "createMissingLabel": { - "type": "boolean" + "gatePack": { + "type": "string", + "enum": [ + "gittensor", + "oss-anti-slop" + ] }, - "publicSurface": { + "linkedIssueGateMode": { "type": "string", "enum": [ "off", - "comment_and_label", - "comment_only", - "label_only" + "advisory", + "block" ] }, - "includeMaintainerAuthors": { - "type": "boolean" - }, - "requireLinkedIssue": { - "type": "boolean" - }, - "backfillEnabled": { - "type": "boolean" - }, - "privateTrustEnabled": { - "type": "boolean" - }, - "commandAuthorization": { - "type": "object", - "properties": { - "default": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "maintainer", - "collaborator", - "pr_author", - "confirmed_miner" - ] - } - }, - "commands": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "maintainer", - "collaborator", - "pr_author", - "confirmed_miner" - ] - } - } - } - }, - "required": [ - "default", - "commands" + "duplicatePrGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" ] }, - "createdAt": { + "qualityGateMode": { "type": "string", - "nullable": true + "enum": [ + "off", + "advisory", + "block" + ] }, - "updatedAt": { - "type": "string", + "qualityGateMinScore": { + "type": "number", "nullable": true }, - "linkedIssueGateMode": { + "slopGateMode": { "type": "string", "enum": [ "off", @@ -8401,7 +8523,7 @@ "block" ] }, - "duplicatePrGateMode": { + "sizeGateMode": { "type": "string", "enum": [ "off", @@ -8409,7 +8531,7 @@ "block" ] }, - "qualityGateMode": { + "lockfileIntegrityGateMode": { "type": "string", "enum": [ "off", @@ -8417,18 +8539,35 @@ "block" ] }, - "qualityGateMinScore": { - "type": "number", - "nullable": true + "gateDryRun": { + "type": "boolean" }, - "gatePack": { + "premergeContentRecheck": { + "type": "boolean" + }, + "requireFreshRebaseWindowMinutes": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true + }, + "mergeReadinessGateMode": { "type": "string", "enum": [ - "gittensor", - "oss-anti-slop" + "off", + "advisory", + "block" ] }, - "slopGateMode": { + "manifestPolicyGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "selfAuthoredLinkedIssueGateMode": { "type": "string", "enum": [ "off", @@ -8436,6 +8575,9 @@ "block" ] }, + "firstTimeContributorGrace": { + "type": "boolean" + }, "slopGateMinScore": { "type": "number", "nullable": true @@ -8443,7 +8585,7 @@ "slopAiAdvisory": { "type": "boolean" }, - "mergeReadinessGateMode": { + "aiReviewMode": { "type": "string", "enum": [ "off", @@ -8451,20 +8593,171 @@ "block" ] }, - "firstTimeContributorGrace": { + "aiReviewByok": { "type": "boolean" }, - "manifestPolicyGateMode": { + "aiReviewProvider": { "type": "string", + "nullable": true, "enum": [ - "off", - "advisory", - "block" + "anthropic", + "openai", + null ] }, - "autonomy": { - "type": "object", - "properties": { + "aiReviewModel": { + "type": "string", + "nullable": true + }, + "aiReviewAllAuthors": { + "type": "boolean" + }, + "aiReviewCloseConfidence": { + "type": "number", + "nullable": true + }, + "aiReviewCombine": { + "type": "string", + "nullable": true, + "enum": [ + "single", + "consensus", + "synthesis", + null + ] + }, + "aiReviewOnMerge": { + "type": "string", + "nullable": true, + "enum": [ + "either", + "both", + null + ] + }, + "aiReviewReviewers": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback": { + "type": "string", + "nullable": true + } + }, + "required": [ + "model" + ] + } + }, + "closeOwnerAuthors": { + "type": "boolean" + }, + "autoLabelEnabled": { + "type": "boolean" + }, + "gittensorLabel": { + "type": "string" + }, + "blacklistLabel": { + "type": "string" + }, + "createMissingLabel": { + "type": "boolean" + }, + "publicSurface": { + "type": "string", + "enum": [ + "off", + "comment_and_label", + "comment_only", + "label_only" + ] + }, + "includeMaintainerAuthors": { + "type": "boolean" + }, + "requireLinkedIssue": { + "type": "boolean" + }, + "backfillEnabled": { + "type": "boolean" + }, + "privateTrustEnabled": { + "type": "boolean" + }, + "badgeEnabled": { + "type": "boolean" + }, + "commandAuthorization": { + "type": "object", + "properties": { + "default": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "maintainer", + "collaborator", + "pr_author", + "confirmed_miner" + ] + } + }, + "commands": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "maintainer", + "collaborator", + "pr_author", + "confirmed_miner" + ] + } + } + } + }, + "required": [ + "default", + "commands" + ] + }, + "contributorBlacklist": { + "type": "array", + "items": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + }, + "addedAt": { + "type": "string" + } + }, + "required": [ + "login" + ] + } + }, + "autonomy": { + "type": "object", + "properties": { "review": { "type": "string", "enum": [ @@ -8553,43 +8846,6 @@ "agentDryRun": { "type": "boolean" }, - "selfAuthoredLinkedIssueGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "contributorBlacklist": { - "type": "array", - "items": { - "type": "object", - "properties": { - "login": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "evidence": { - "type": "array", - "items": { - "type": "string" - } - }, - "addedAt": { - "type": "string" - } - }, - "required": [ - "login" - ] - } - }, - "blacklistLabel": { - "type": "string" - }, "contributorOpenPrCap": { "type": "integer", "nullable": true, @@ -8602,6 +8858,9 @@ "minimum": 0, "exclusiveMinimum": true }, + "contributorCapLabel": { + "type": "string" + }, "reviewNagPolicy": { "type": "string", "enum": [ @@ -8630,66 +8889,6 @@ "type": "string" } }, - "contributorCapLabel": { - "type": "string" - }, - "sizeGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "gateDryRun": { - "type": "boolean" - }, - "premergeContentRecheck": { - "type": "boolean" - }, - "aiReviewMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "aiReviewByok": { - "type": "boolean" - }, - "aiReviewProvider": { - "type": "string", - "nullable": true, - "enum": [ - "anthropic", - "openai", - null - ] - }, - "aiReviewModel": { - "type": "string", - "nullable": true - }, - "aiReviewAllAuthors": { - "type": "boolean" - }, - "aiReviewCloseConfidence": { - "type": "number", - "nullable": true - }, - "closeOwnerAuthors": { - "type": "boolean" - }, - "badgeEnabled": { - "type": "boolean" - }, - "requireFreshRebaseWindowMinutes": { - "type": "integer", - "nullable": true, - "minimum": 0, - "exclusiveMinimum": true - }, "accountAgeThresholdDays": { "type": "integer", "nullable": true, @@ -8721,13 +8920,13 @@ "minimum": 0, "exclusiveMinimum": true }, - "lockfileIntegrityGateMode": { + "createdAt": { "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true } }, "required": [ @@ -9222,67 +9421,11 @@ "enabled" ] }, - "autoLabelEnabled": { - "type": "boolean" - }, - "gittensorLabel": { - "type": "string" - }, - "createMissingLabel": { - "type": "boolean" - }, - "includeMaintainerAuthors": { - "type": "boolean" - }, - "requireLinkedIssue": { - "type": "boolean" - }, - "commandAuthorization": { - "type": "object", - "properties": { - "defaultAllowed": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "maintainer", - "collaborator", - "pr_author", - "confirmed_miner" - ] - } - }, - "commandOverrides": { - "type": "array", - "items": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "allowedRoles": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "maintainer", - "collaborator", - "pr_author", - "confirmed_miner" - ] - } - } - }, - "required": [ - "command", - "allowedRoles" - ] - } - } - }, - "required": [ - "defaultAllowed", - "commandOverrides" + "gatePack": { + "type": "string", + "enum": [ + "gittensor", + "oss-anti-slop" ] }, "linkedIssueGateMode": { @@ -9313,13 +9456,6 @@ "type": "number", "nullable": true }, - "gatePack": { - "type": "string", - "enum": [ - "gittensor", - "oss-anti-slop" - ] - }, "slopGateMode": { "type": "string", "enum": [ @@ -9328,10 +9464,6 @@ "block" ] }, - "slopGateMinScore": { - "type": "number", - "nullable": true - }, "mergeReadinessGateMode": { "type": "string", "enum": [ @@ -9340,9 +9472,6 @@ "block" ] }, - "firstTimeContributorGrace": { - "type": "boolean" - }, "manifestPolicyGateMode": { "type": "string", "enum": [ @@ -9359,9 +9488,31 @@ "block" ] }, + "firstTimeContributorGrace": { + "type": "boolean" + }, + "slopGateMinScore": { + "type": "number", + "nullable": true + }, + "autoLabelEnabled": { + "type": "boolean" + }, + "gittensorLabel": { + "type": "string" + }, "blacklistLabel": { "type": "string" }, + "createMissingLabel": { + "type": "boolean" + }, + "includeMaintainerAuthors": { + "type": "boolean" + }, + "requireLinkedIssue": { + "type": "boolean" + }, "badgeEnabled": { "type": "boolean" }, @@ -9386,6 +9537,54 @@ }, "aiReviewAllAuthors": { "type": "boolean" + }, + "commandAuthorization": { + "type": "object", + "properties": { + "defaultAllowed": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "maintainer", + "collaborator", + "pr_author", + "confirmed_miner" + ] + } + }, + "commandOverrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "allowedRoles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "maintainer", + "collaborator", + "pr_author", + "confirmed_miner" + ] + } + } + }, + "required": [ + "command", + "allowedRoles" + ] + } + } + }, + "required": [ + "defaultAllowed", + "commandOverrides" + ] } }, "required": [ @@ -12839,213 +13038,114 @@ "maintainerNextSteps", "privateSummary" ] + } + }, + "parameters": {}, + "securitySchemes": { + "GittensoryBearer": { + "type": "http", + "scheme": "bearer", + "description": "Static API/MCP token, GitHub device-flow Gittensory session token, or extension-scoped Gittensory session token where supported. GitHub personal access tokens are not accepted." }, - "PublicRepoStats": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "htmlUrl": { - "type": "string" - }, - "stargazers_count": { - "type": "number" - }, - "forks_count": { - "type": "number" - }, - "fetched_at": { - "type": "string" - }, - "source": { - "type": "string", - "enum": [ - "github", - "cache", - "stale_cache" - ] - }, - "stale": { - "type": "boolean" + "GittensorySessionCookie": { + "type": "apiKey", + "in": "cookie", + "name": "gittensory_session", + "description": "HttpOnly browser session cookie set by GitHub web OAuth." + } + } + }, + "paths": { + "/health": { + "get": { + "responses": { + "200": { + "description": "Service health", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } } - }, - "required": [ - "repoFullName", - "htmlUrl", - "stargazers_count", - "forks_count", - "fetched_at", - "source", - "stale" - ] - }, - "PublicStats": { - "type": "object", - "properties": { - "generatedAt": { - "type": "string" + } + } + }, + "/v1/mcp/compatibility": { + "get": { + "responses": { + "200": { + "description": "Public-safe API and MCP compatibility metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpCompatibility" + } + } + } + } + } + } + }, + "/v1/public/stats": { + "get": { + "responses": { + "200": { + "description": "Public-safe homepage stats: lifetime PRs handled/merged/closed, gate + slop blocks, and reversal-grounded accuracy. Aggregate counts only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStats" + } + } + } }, - "updatedAt": { - "type": "string" + "404": { + "description": "Public stats are disabled (GITTENSORY_PUBLIC_STATS off)" }, - "totals": { - "type": "object", - "properties": { - "handled": { - "type": "number" - }, - "merged": { - "type": "number" - }, - "closed": { - "type": "number" - }, - "reversed": { - "type": "number" - }, - "accuracyPct": { - "type": "number", - "nullable": true - }, - "commented": { - "type": "number" - }, - "ignored": { - "type": "number" - }, - "manual": { - "type": "number" - }, - "error": { - "type": "number" - }, - "reviewed": { - "type": "number" - }, - "filteredPct": { - "type": "number", - "nullable": true - }, - "minutesSaved": { - "type": "number" - } - }, - "required": [ - "handled", - "reviewed", - "merged", - "closed", - "commented", - "ignored", - "manual", - "error", - "reversed", - "filteredPct", - "accuracyPct", - "minutesSaved" - ] - }, - "weekly": { - "type": "object", - "properties": { - "merged": { - "type": "number" - }, - "reviewed": { - "type": "number" - } - }, - "required": [ - "reviewed", - "merged" - ] - }, - "byProject": { - "type": "array", - "items": { - "type": "object", - "properties": { - "project": { - "type": "string" - }, - "merged": { - "type": "number" - }, - "closed": { - "type": "number" - }, - "accuracyPct": { - "type": "number", - "nullable": true - }, - "reviewed": { - "type": "number" - } - }, - "required": [ - "project", - "reviewed", - "merged", - "closed", - "accuracyPct" - ] - } - } - }, - "required": [ - "generatedAt", - "updatedAt", - "totals", - "weekly", - "byProject" - ] - } - }, - "parameters": {}, - "securitySchemes": { - "GittensoryBearer": { - "type": "http", - "scheme": "bearer", - "description": "Static API/MCP token, GitHub device-flow Gittensory session token, or extension-scoped Gittensory session token where supported. GitHub personal access tokens are not accepted." - }, - "GittensorySessionCookie": { - "type": "apiKey", - "in": "cookie", - "name": "gittensory_session", - "description": "HttpOnly browser session cookie set by GitHub web OAuth." - } - } - }, - "paths": { - "/health": { - "get": { - "responses": { - "200": { - "description": "Service health", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Health" - } - } - } + "503": { + "description": "Public stats are temporarily unavailable" } } } }, - "/v1/mcp/compatibility": { + "/v1/public/github/repos/{owner}/{repo}/stats": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], "responses": { "200": { - "description": "Public-safe API and MCP compatibility metadata", + "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/McpCompatibility" + "$ref": "#/components/schemas/PublicRepoStats" } } } + }, + "400": { + "description": "Invalid or non-allowlisted GitHub repository" + }, + "503": { + "description": "GitHub repository stats are unavailable" } } } @@ -13736,11 +13836,11 @@ ] } }, - "/v1/app/self-dogfood/registration-pack": { + "/v1/repos/{owner}/{repo}/focus-manifest": { "get": { "responses": { "200": { - "description": "Private self-dogfood registration pack for the Gittensory repo", + "description": "Repo focus manifest and compiled policy for maintainers", "content": { "application/json": { "schema": { @@ -13753,7 +13853,7 @@ } }, "403": { - "description": "Insufficient role for maintainer-only self-dogfood report" + "description": "Insufficient role" } }, "security": [ @@ -13764,13 +13864,11 @@ "GittensorySessionCookie": [] } ] - } - }, - "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack": { - "get": { + }, + "put": { "responses": { "200": { - "description": "Private self-dogfood registration pack when repo matches configured Gittensory target", + "description": "Persist API-backed focus manifest for a repo", "content": { "application/json": { "schema": { @@ -13782,8 +13880,11 @@ } } }, + "400": { + "description": "Malformed JSON request body" + }, "403": { - "description": "Insufficient role or repo is not the configured self-dogfood target" + "description": "Insufficient role" } }, "security": [ @@ -13796,11 +13897,11 @@ ] } }, - "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { - "get": { + "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { + "post": { "responses": { "200": { - "description": "Preview-only repo onboarding pack for accepted repositories", + "description": "Refresh the persisted focus manifest cache from the repo file", "content": { "application/json": { "schema": { @@ -13814,9 +13915,6 @@ }, "403": { "description": "Insufficient role" - }, - "404": { - "description": "Repository is not accepted or preview unavailable" } }, "security": [ @@ -13829,21 +13927,223 @@ ] } }, - "/v1/repos/{owner}/{repo}/settings": { + "/v1/repos/{owner}/{repo}/agent/audit-feed": { "get": { "responses": { "200": { - "description": "Gittensory repository automation settings", + "description": "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositorySettings" + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "eventType": { + "type": "string" + }, + "pullNumber": { + "type": "number", + "nullable": true + }, + "outcome": { + "type": "string" + }, + "actor": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "eventType", + "pullNumber", + "outcome", + "actor", + "detail", + "createdAt" + ] + } + } + }, + "required": [ + "repoFullName", + "events" + ] } } } - } - }, - "security": [ + }, + "400": { + "description": "Malformed since (not ISO-8601) or limit (not an integer in 1-200)" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/app/self-dogfood/registration-pack": { + "get": { + "responses": { + "200": { + "description": "Private self-dogfood registration pack for the Gittensory repo", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role for maintainer-only self-dogfood report" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack": { + "get": { + "responses": { + "200": { + "description": "Private self-dogfood registration pack when repo matches configured Gittensory target", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role or repo is not the configured self-dogfood target" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { + "get": { + "responses": { + "200": { + "description": "Preview-only repo onboarding pack for accepted repositories", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Repository is not accepted or preview unavailable" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { + "responses": { + "200": { + "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/settings": { + "get": { + "responses": { + "200": { + "description": "Gittensory repository automation settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySettings" + } + } + } + } + }, + "security": [ { "GittensoryBearer": [] }, @@ -14500,6 +14800,26 @@ } } }, + "/v1/orb/ingest": { + "post": { + "responses": { + "200": { + "description": "Batch accepted; returns { accepted: number }" + }, + "400": { + "description": "Malformed JSON or invalid payload shape" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/auth/github/start": { "get": { "responses": { @@ -15589,288 +15909,6 @@ } ] } - }, - "/v1/public/github/repos/{owner}/{repo}/stats": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicRepoStats" - } - } - } - }, - "400": { - "description": "Invalid or non-allowlisted GitHub repository" - }, - "503": { - "description": "GitHub repository stats are unavailable" - } - } - } - }, - "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { - "post": { - "responses": { - "200": { - "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest": { - "get": { - "responses": { - "200": { - "description": "Repo focus manifest and compiled policy for maintainers", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - }, - "put": { - "responses": { - "200": { - "description": "Persist API-backed focus manifest for a repo", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Malformed JSON request body" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { - "post": { - "responses": { - "200": { - "description": "Refresh the persisted focus manifest cache from the repo file", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/repos/{owner}/{repo}/agent/audit-feed": { - "get": { - "responses": { - "200": { - "description": "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "events": { - "type": "array", - "items": { - "type": "object", - "properties": { - "eventType": { - "type": "string" - }, - "pullNumber": { - "type": "number", - "nullable": true - }, - "outcome": { - "type": "string" - }, - "actor": { - "type": "string", - "nullable": true - }, - "detail": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "eventType", - "pullNumber", - "outcome", - "actor", - "detail", - "createdAt" - ] - } - } - }, - "required": [ - "repoFullName", - "events" - ] - } - } - } - }, - "400": { - "description": "Malformed since (not ISO-8601) or limit (not an integer in 1-200)" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/public/stats": { - "get": { - "responses": { - "200": { - "description": "Public-safe homepage stats: lifetime PRs handled/merged/closed, gate + slop blocks, and reversal-grounded accuracy. Aggregate counts only.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicStats" - } - } - } - }, - "404": { - "description": "Public stats are disabled (GITTENSORY_PUBLIC_STATS off)" - }, - "503": { - "description": "Public stats are temporarily unavailable" - } - } - } - }, - "/v1/orb/ingest": { - "post": { - "responses": { - "200": { - "description": "Batch accepted; returns { accepted: number }" - }, - "400": { - "description": "Malformed JSON or invalid payload shape" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 77a367bd36..4df156907d 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -613,6 +613,12 @@ export const RepositorySettingsSchema = z aiReviewModel: z.string().nullable().optional(), aiReviewAllAuthors: z.boolean(), aiReviewCloseConfidence: z.number().nullable().optional(), + aiReviewCombine: z.enum(["single", "consensus", "synthesis"]).nullable().optional(), + aiReviewOnMerge: z.enum(["either", "both"]).nullable().optional(), + aiReviewReviewers: z + .array(z.object({ model: z.string(), fallback: z.string().nullable().optional() })) + .nullable() + .optional(), closeOwnerAuthors: z.boolean(), autoLabelEnabled: z.boolean(), gittensorLabel: z.string(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a4ea99cc1c..175776b21a 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5450,6 +5450,13 @@ export async function runAiReviewForAdvisory( observability: { rag: ragTelemetry }, enrichment, profile: args.reviewProfile ?? null, + // Per-repo dual-AI combine/onMerge/reviewers overrides (#2567), resolved by resolveEffectiveSettings from + // `.gittensory.yml gate.aiReview.*` onto `args.settings`. Absent ⇒ undefined ⇒ runGittensoryAiReview falls + // back to the operator's AI_REVIEW_PLAN (byte-identical to today). `onMerge` is clamped to the operator's + // floor INSIDE runGittensoryAiReview (resolveEffectiveAiReviewOnMerge), not here. + combine: args.settings.aiReviewCombine ?? undefined, + onMerge: args.settings.aiReviewOnMerge ?? undefined, + reviewers: args.settings.aiReviewReviewers ?? undefined, securityFocus: args.reviewSecurityFocus === true, // Inline comments (#inline-comments): ask the model for line-anchored findings only when the operator flag, // the cutover allowlist, AND the per-repo manifest toggle all pass. Otherwise the prompt is byte-identical. diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index f5dedee730..ad4ef732ce 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -30,6 +30,7 @@ import { errorMessage } from "../utils/json"; import type { ReviewProfile } from "../signals/focus-manifest"; import { isCodeFile } from "../signals/local-branch"; import { isTestPath } from "../signals/test-evidence"; +import type { CombineStrategy, OnMerge } from "../types"; /** * The best free Workers-AI model pair for review accuracy — two different families for independence, @@ -79,17 +80,36 @@ export type AiReviewProviderKey = { model?: string | null | undefined; }; +// `CombineStrategy` / `OnMerge` (#dual-ai-combiner) are defined in ../types.ts, not here, and re-exported for +// backward compat: both this file's own callers AND signals/focus-manifest.ts + types.ts's RepositorySettings +// need the type, but focus-manifest.ts/types.ts are imported by the UI workspace, which lacks the ambient +// Cloudflare Workers types (`Env`, `D1Database`, …) this file's runtime code depends on — a type-only +// `import("../services/ai-review")` reference from either would still drag this whole module graph into the UI's +// typecheck and break it (#2567 follow-up fix). See ../types.ts for the full doc comment. +export type { CombineStrategy, OnMerge } from "../types"; + /** - * How the independent reviewer opinions are combined into ONE gate decision (#dual-ai-combiner): - * • `single` — one reviewer; its verdict IS the decision (a named blocker blocks). - * • `consensus` — two reviewers; block ONLY when BOTH name a blocker; lone blocker → split (hold). The - * historical cloud behavior — the default, so an unset `combine` is byte-identical. - * • `synthesis` — two reviewers run separately, then merge into ONE decision (no split/hold-on-disagree): - * `onMerge: either` blocks if EITHER flags a blocker; `both` only if all do. + * Resolve the EFFECTIVE `onMerge` rule for a review call, enforcing that a per-repo `.gittensory.yml + * gate.aiReview.onMerge` override (#2567) can only TIGHTEN the self-host operator's `AI_REVIEW_PLAN.onMerge` + * floor, never loosen it. `either` is the STRICTER rule (any one reviewer's blocker blocks/holds); `both` is + * more PERMISSIVE (requires every reviewer to agree before a blocker counts). So: + * - operator floor `either` + repo override `both` → CLAMPED to `either` (an attempted loosening). + * - operator floor `either` + repo override `either` → `either` (a no-op tightening). + * - operator floor `both` (or unset) → the repo override (or the operator's own value) wins + * unclamped — there is no stricter floor to violate. + * Returns the resolved value alongside whether a clamp fired, so the caller can log/surface it (a maintainer + * who configured a loosening override should see it was not honored, not have it silently ignored). */ -export type CombineStrategy = "single" | "consensus" | "synthesis"; -/** Synthesis merge rule — block if `either` reviewer flags a blocker, or only when `both` agree. */ -export type OnMerge = "either" | "both"; +export function resolveEffectiveAiReviewOnMerge( + repoOverride: OnMerge | null | undefined, + operatorFloor: OnMerge | null | undefined, +): { onMerge: OnMerge | null | undefined; clamped: boolean } { + if (repoOverride == null) return { onMerge: operatorFloor, clamped: false }; + if (operatorFloor === "either" && repoOverride === "both") { + return { onMerge: "either", clamped: true }; + } + return { onMerge: repoOverride, clamped: false }; +} export type GittensoryAiReviewInput = { repoFullName: string; @@ -1115,7 +1135,15 @@ export async function runGittensoryAiReview( const secondaryFallback = secondary.fallback ?? secondary.model; const combine: CombineStrategy = input.combine ?? plan?.combine ?? "consensus"; - const onMerge: OnMerge | null | undefined = input.onMerge ?? plan?.onMerge; + // `onMerge` is a per-repo REFINEMENT of the operator's plan, never a bypass (#2567): a repo can only TIGHTEN + // the operator's floor (never loosen `either` down to `both`). resolveEffectiveAiReviewOnMerge enforces the + // clamp; a fired clamp increments a metric so it is surfaced, not silently ignored (mirrors the + // gittensory_ai_review_inconclusive_total pattern below). + const onMergeResolution = resolveEffectiveAiReviewOnMerge(input.onMerge, plan?.onMerge); + const onMerge = onMergeResolution.onMerge; + if (onMergeResolution.clamped) { + incr("gittensory_ai_review_onmerge_clamped_total", { mode: input.mode }); + } const dual = combine !== "single" && (!configured || configured.length > 1); const freeAiCalls = (input.mode === "block" ? (dual ? 2 : 1) : 0) + (input.providerKey ? 0 : 1); diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index d0b0ca5b50..51beb9608e 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -45,6 +45,23 @@ export type FocusManifestGateConfig = { /** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK * under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */ aiReviewCloseConfidence: number | null; + /** `gate.aiReview.combine` (#2567): per-repo override of the self-host operator's `AI_REVIEW_PLAN.combine` + * boot default (single/consensus/synthesis). null (unset) ⇒ the operator's plan (or `consensus`). A + * REFINEMENT only — see {@link aiReviewOnMerge} for the operator-floor clamp `runGittensoryAiReview` applies + * to the paired `onMerge` field; `combine` itself is not floor-clamped (the three strategies are not ordered + * by strictness, so there is no single "loosening" direction to clamp). */ + aiReviewCombine: import("../types").CombineStrategy | null; + /** `gate.aiReview.onMerge` (#2567): per-repo override of the `synthesis` merge rule. `either` is the STRICTER + * rule (any one reviewer's blocker blocks/holds); `both` is more PERMISSIVE (requires every reviewer to + * agree). null (unset) ⇒ the operator's `AI_REVIEW_PLAN.onMerge`. A repo may only TIGHTEN the operator's + * floor (never loosen `either` down to `both`) — `runGittensoryAiReview` enforces the clamp at resolve time, + * since only it can see both the per-repo value and the operator's plan. */ + aiReviewOnMerge: import("../types").OnMerge | null; + /** `gate.aiReview.reviewers` (#2567): per-repo override of the named reviewer pair(s) to run, in place of the + * operator's `AI_REVIEW_PLAN.reviewers` (or the free Workers-AI pair when the operator configured none). null + * (unset) ⇒ the operator's plan. No operator floor applies to WHICH reviewers run (only `onMerge` gates + * strictness), so this always wins unclamped when set. */ + aiReviewReviewers: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null; mergeReadiness: GateRuleMode | null; manifestPolicy: GateRuleMode | null; selfAuthoredLinkedIssue: GateRuleMode | null; @@ -320,6 +337,9 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, + aiReviewCombine: null, + aiReviewOnMerge: null, + aiReviewReviewers: null, mergeReadiness: null, manifestPolicy: null, selfAuthoredLinkedIssue: null, @@ -494,6 +514,49 @@ function normalizeOptionalConfidence(value: JsonValue | undefined, field: string return Math.max(0, Math.min(1, value)); } +// A hard cap on `gate.aiReview.reviewers` entries — the combiner only ever addresses reviewer[0]/[1] (single runs +// one, consensus/synthesis run two), so anything beyond 2 is inert; capping at 4 leaves headroom without letting a +// hostile/huge manifest bloat the parsed config for no functional gain. +const MAX_AI_REVIEW_REVIEWERS = 4; + +/** Normalize `gate.aiReview.reviewers` (#2567) — a list of `{ model, fallback? }` entries naming self-host + * providers (e.g. `claude-code`, `codex`) to run in place of the operator's `AI_REVIEW_PLAN.reviewers`. Each + * entry needs a non-empty string `model`; `fallback` is optional and, when present, must also be a non-empty + * string. Invalid entries are dropped with a warning rather than failing the whole list, mirroring the other + * manifest list parsers. Absent/empty/all-invalid ⇒ null (so the resolver's `??` fallback to the operator's + * plan is untouched). */ +function normalizeOptionalReviewers( + value: JsonValue | undefined, + field: string, + warnings: string[], +): ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null { + if (value === undefined || value === null) return null; + if (!Array.isArray(value)) { + warnings.push(`Manifest gate field "${field}" must be a list of { model, fallback? }; ignoring it.`); + return null; + } + const out: Array<{ model: string; fallback?: string | null | undefined }> = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_AI_REVIEW_REVIEWERS) { + warnings.push(`Manifest gate field "${field}" is capped at ${MAX_AI_REVIEW_REVIEWERS} entries; dropping the rest.`); + break; + } + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + warnings.push(`Manifest gate field "${field}[${index}]" must be a mapping with a "model" string; ignoring it.`); + continue; + } + const e = entry as Record; + const model = typeof e.model === "string" ? e.model.trim() : ""; + if (!model) { + warnings.push(`Manifest gate field "${field}[${index}].model" must be a non-empty string; ignoring the entry.`); + continue; + } + const fallback = typeof e.fallback === "string" && e.fallback.trim() ? e.fallback.trim() : undefined; + out.push(fallback ? { model, fallback } : { model }); + } + return out.length > 0 ? out : null; +} + /** * Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer * this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted. @@ -544,6 +607,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings), aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings), + aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"] as const, warnings), + aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"] as const, warnings), + aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings), mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), @@ -577,6 +643,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.aiReviewModel !== null || gate.aiReviewAllAuthors !== null || gate.aiReviewCloseConfidence !== null || + gate.aiReviewCombine !== null || + gate.aiReviewOnMerge !== null || + gate.aiReviewReviewers !== null || gate.mergeReadiness !== null || gate.manifestPolicy !== null || gate.selfAuthoredLinkedIssue !== null || @@ -613,7 +682,17 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.slopAiAdvisory !== null) slop.aiAdvisory = gate.slopAiAdvisory; out.slop = slop; } - if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null || gate.aiReviewAllAuthors !== null || gate.aiReviewCloseConfidence !== null) { + if ( + gate.aiReviewMode !== null || + gate.aiReviewByok !== null || + gate.aiReviewProvider !== null || + gate.aiReviewModel !== null || + gate.aiReviewAllAuthors !== null || + gate.aiReviewCloseConfidence !== null || + gate.aiReviewCombine !== null || + gate.aiReviewOnMerge !== null || + gate.aiReviewReviewers !== null + ) { const aiReview: Record = {}; if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode; if (gate.aiReviewByok !== null) aiReview.byok = gate.aiReviewByok; @@ -621,6 +700,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel; if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors; if (gate.aiReviewCloseConfidence !== null) aiReview.closeConfidence = gate.aiReviewCloseConfidence; + if (gate.aiReviewCombine !== null) aiReview.combine = gate.aiReviewCombine; + if (gate.aiReviewOnMerge !== null) aiReview.onMerge = gate.aiReviewOnMerge; + if (gate.aiReviewReviewers !== null) { + aiReview.reviewers = gate.aiReviewReviewers.map((r) => + r.fallback ? { model: r.model, fallback: r.fallback } : { model: r.model }, + ) as JsonValue; + } out.aiReview = aiReview; } if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; @@ -1272,6 +1358,14 @@ export function resolveEffectiveSettings( if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel; if (gate.aiReviewAllAuthors !== null) effective.aiReviewAllAuthors = gate.aiReviewAllAuthors; if (gate.aiReviewCloseConfidence !== null) effective.aiReviewCloseConfidence = gate.aiReviewCloseConfidence; + // Dual-AI combine/onMerge/reviewers overrides (#2567) are projected onto `effective` unclamped here — they are + // a REFINEMENT of the operator's AI_REVIEW_PLAN, not a replacement for it, so the actual operator-floor clamp + // (onMerge can only TIGHTEN, never loosen) happens where both the per-repo value AND the operator's plan are + // visible: `resolveEffectiveAiReviewOnMerge` in services/ai-review.ts, called from the review call site. This + // resolver has no access to `env.AI_REVIEW_PLAN`, so it cannot itself enforce the floor. + if (gate.aiReviewCombine !== null) effective.aiReviewCombine = gate.aiReviewCombine; + if (gate.aiReviewOnMerge !== null) effective.aiReviewOnMerge = gate.aiReviewOnMerge; + if (gate.aiReviewReviewers !== null) effective.aiReviewReviewers = gate.aiReviewReviewers; if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue; diff --git a/src/types.ts b/src/types.ts index f269bd92a1..82f4f12b8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -519,6 +519,24 @@ export type GateRuleMode = "off" | "advisory" | "block"; * consensus) block ANY author, with no emissions/registry/Gittensor coupling — so the gate runs on any repo. */ export type GatePolicyPack = "gittensor" | "oss-anti-slop"; +/** + * How the independent AI-reviewer opinions are combined into ONE gate decision (#dual-ai-combiner). Canonical + * definition lives here (not in `services/ai-review.ts`, which re-exports it) because both `RepositorySettings` + * below and `signals/focus-manifest.ts` need it, and BOTH are imported by the UI workspace — `services/ai-review.ts` + * pulls in ambient Cloudflare Workers types (`Env`, `D1Database`, …) the UI's tsconfig `lib` doesn't declare, so a + * `import("../services/ai-review").CombineStrategy` type-only reference from either file would still drag that + * whole module graph into the UI's typecheck and break it (#2567 follow-up fix). + * • `single` — one reviewer; its verdict IS the decision (a named blocker blocks). + * • `consensus` — two reviewers; block ONLY when BOTH name a blocker; lone blocker → split (hold). The + * historical cloud behavior — the default, so an unset `combine` is byte-identical. + * • `synthesis` — two reviewers run separately, then merge into ONE decision (no split/hold-on-disagree): + * `onMerge: either` blocks if EITHER flags a blocker; `both` only if all do. + */ +export type CombineStrategy = "single" | "consensus" | "synthesis"; +/** Synthesis merge rule — block if `either` reviewer flags a blocker, or only when `both` agree. See + * {@link CombineStrategy} for why the canonical definition lives here rather than `services/ai-review.ts`. */ +export type OnMerge = "either" | "both"; + export type RepositorySettings = { repoFullName: string; commentMode: "off" | "detected_contributors_only" | "all_prs"; @@ -611,6 +629,27 @@ export type RepositorySettings = { * gate.aiReview.closeConfidence` (no dashboard/DB column); unset ⇒ the gate uses the 0.93 default. Clamped to * [0,1] at parse time. */ aiReviewCloseConfidence?: number | null | undefined; + /** Per-repo dual-AI combine-strategy override (#2567). Config-as-code only — set via `.gittensory.yml + * gate.aiReview.combine` (no dashboard/DB column); unset ⇒ the self-host operator's `AI_REVIEW_PLAN.combine` + * boot config (or `consensus` if the operator set nothing). A REFINEMENT of the operator's plan, not a + * bypass — `runGittensoryAiReview` clamps the resolved `onMerge` to the operator's floor (see + * {@link aiReviewOnMerge}); `combine` itself carries no floor semantics (single/consensus/synthesis are not + * ordered by strictness). */ + aiReviewCombine?: CombineStrategy | null | undefined; + /** Per-repo `synthesis` merge-rule override (#2567): `either` blocks on ANY one reviewer's blocker (the + * STRICTER rule); `both` blocks only when every reviewer agrees (the more PERMISSIVE rule). Config-as-code + * only — set via `.gittensory.yml gate.aiReview.onMerge` (no dashboard/DB column). A repo override can only + * TIGHTEN the operator's `AI_REVIEW_PLAN.onMerge` floor (e.g. `either` → `either` is a no-op; `both` → an + * attempted loosening is clamped back to `either`). When the operator has not set an `onMerge` floor, any + * per-repo value is honored unclamped. See `resolveEffectiveAiReviewOnMerge` in `services/ai-review.ts`. */ + aiReviewOnMerge?: OnMerge | null | undefined; + /** Per-repo reviewer-pair override (#2567): named self-host providers (e.g. `{ model: "claude-code" }`, + * `{ model: "codex" }`) to run instead of the operator's `AI_REVIEW_PLAN.reviewers` (or the free Workers-AI + * pair when the operator configured none). Config-as-code only — set via `.gittensory.yml + * gate.aiReview.reviewers` (no dashboard/DB column). Unlike {@link aiReviewOnMerge}, WHICH reviewers run + * carries no operator floor to violate (the floor is what triggers a hold/block, not who evaluates it), so a + * repo override always wins unclamped when set. */ + aiReviewReviewers?: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null | undefined; /** When TRUE, the repo OWNER's (and maintainer's) own PRs are eligible for auto-CLOSE like a contributor's * (still subject to the `close` autonomy class + the same adverse-signal conditions). Default FALSE — owner * PRs are exempt from auto-close (merge or manual-hold only). Per-repo configurable so maintainers choose diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index dfc9b96454..f772f39e67 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -256,6 +256,54 @@ describe("runAiReviewForAdvisory", () => { expect(result?.notes).toContain("Likely crash."); }); + it("threads settings.aiReviewCombine/aiReviewOnMerge/aiReviewReviewers (#2567) into the AI review call", async () => { + // settings.aiReviewCombine/OnMerge/Reviewers are resolved from `.gittensory.yml gate.aiReview.*` upstream by + // resolveEffectiveSettings; runAiReviewForAdvisory must forward them into runGittensoryAiReview's input so a + // per-repo override actually reaches the reviewer selection (in place of any env.AI_REVIEW_PLAN default). + const adv = advisory(); + const seen: string[] = []; + const run = (async (model: string) => { + seen.push(model); + // Only "codex" flags a blocker; under a "single" combine strategy only reviewer[0] ("codex") is addressed. + return { response: model === "codex" ? defectJson() : notesOnlyJson() }; + }) as unknown as () => Promise; + const result = await runAiReviewForAdvisory(aiEnv(run), { + settings: { + aiReviewMode: "block", + aiReviewCombine: "single", + aiReviewReviewers: [{ model: "codex" }], + } as unknown as RepositorySettings, + advisory: adv, + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: true, + }); + expect(seen).toEqual(["codex"]); // the per-repo reviewer override ran instead of the default Workers-AI pair + expect(adv.findings.map((f) => f.code)).toEqual(["ai_consensus_defect"]); + expect(result?.notes).toContain("Likely crash."); + }); + + it("a repo without an aiReviewCombine/OnMerge/Reviewers override sees zero behavior change (default Workers-AI pair + consensus)", async () => { + const adv = advisory(); + const seen: string[] = []; + const run = (async (model: string) => { + seen.push(model); + return { response: defectJson() }; + }) as unknown as () => Promise; + const result = await runAiReviewForAdvisory(aiEnv(run), { + settings: { aiReviewMode: "block" } as RepositorySettings, // no aiReviewCombine/OnMerge/Reviewers set + advisory: adv, + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: true, + }); + expect([...seen].sort()).toEqual([...BEST_REVIEW_MODELS].sort()); // default Workers-AI pair, byte-identical + expect(adv.findings.map((f) => f.code)).toEqual(["ai_consensus_defect"]); + expect(result?.notes).toContain("Likely crash."); + }); + it("threads the calibrated MIN consensus confidence onto the ai_consensus_defect finding (#8)", async () => { const adv = advisory(); // Two reviewers agree on a blocker but with DIFFERENT confidences (0.95 vs 0.6) → the finding carries the min. diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index 7adba6ef8a..42e9c0fd75 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -3,6 +3,7 @@ import { __aiReviewInternals, BEST_REVIEW_MODELS, buildTestEvidencePromptSection, + resolveEffectiveAiReviewOnMerge, runGittensoryAiReview, type GittensoryAiReviewInput, } from "../../src/services/ai-review"; @@ -992,6 +993,144 @@ describe("runGittensoryAiReview self-host dual-AI plan (#dual-ai-combiner)", () }); expect([...seen].sort()).toEqual(["groq", "ollama"]); // input reviewers win over the env plan }); + + describe("per-repo onMerge is a REFINEMENT of the operator floor, never a bypass (#2567)", () => { + it("a repo without an override inherits the operator's onMerge floor unchanged", async () => { + const seen: string[] = []; + const env = planEnv( + { + reviewers: [{ model: "claude-code" }, { model: "codex" }], + combine: "synthesis", + onMerge: "either", + }, + async (model) => { + seen.push(model); + // Only codex flags a blocker; under the operator's "either" floor, that alone must decide. + return model === "codex" + ? { response: reviewJson({ present: true, title: "Lone blocker" }) } + : { response: reviewJson({ present: false }) }; + }, + ); + const result = await runGittensoryAiReview(env, { + ...baseInput, + mode: "block", + // No per-repo combine/onMerge/reviewers override at all. + }); + if (result.status !== "ok") throw new Error("expected ok"); + expect(result.consensusDefect?.title).toContain("Lone blocker"); // "either" honored unchanged + expect(await renderMetrics()).not.toContain("gittensory_ai_review_onmerge_clamped_total"); // no clamp fired + }); + + it("a repo tightening either -> either against an either floor is a no-op, not a clamp", async () => { + const env = planEnv( + { reviewers: [{ model: "claude-code" }, { model: "codex" }], combine: "synthesis", onMerge: "either" }, + async (model) => + model === "codex" + ? { response: reviewJson({ present: true, title: "Lone blocker" }) } + : { response: reviewJson({ present: false }) }, + ); + const result = await runGittensoryAiReview(env, { + ...baseInput, + mode: "block", + combine: "synthesis", + onMerge: "either", // same as the floor: a legitimate (no-op) tightening + }); + if (result.status !== "ok") throw new Error("expected ok"); + expect(result.consensusDefect?.title).toContain("Lone blocker"); + expect(await renderMetrics()).not.toContain("gittensory_ai_review_onmerge_clamped_total"); // not a clamp + }); + + it("a repo attempting to LOOSEN either -> both against an either floor is clamped back to either, and it is metered (not silently ignored)", async () => { + const seen: string[] = []; + const env = planEnv( + { reviewers: [{ model: "claude-code" }, { model: "codex" }], combine: "synthesis", onMerge: "either" }, + async (model) => { + seen.push(model); + // Only codex flags a blocker. Under "both" this would NOT block; under the clamped-back "either" it does. + return model === "codex" + ? { response: reviewJson({ present: true, title: "Lone blocker" }) } + : { response: reviewJson({ present: false }) }; + }, + ); + const result = await runGittensoryAiReview(env, { + ...baseInput, + mode: "block", + combine: "synthesis", + onMerge: "both", // an attempted loosening of the operator's "either" floor + }); + if (result.status !== "ok") throw new Error("expected ok"); + // The clamp won: the lone blocker still decides, exactly as it would under "either". + expect(result.consensusDefect?.title).toContain("Lone blocker"); + expect([...seen].sort()).toEqual(["claude-code", "codex"]); + // Surfaced via a metric, not silently dropped. + expect(await renderMetrics()).toContain('gittensory_ai_review_onmerge_clamped_total{mode="block"} 1'); + }); + + it("a repo picking both against a both (or unset) operator floor is honored unclamped", async () => { + const env = planEnv( + { reviewers: [{ model: "claude-code" }, { model: "codex" }], combine: "synthesis", onMerge: "both" }, + async (model) => + model === "codex" + ? { response: reviewJson({ present: true, title: "Lone blocker" }) } + : { response: reviewJson({ present: false }) }, + ); + const result = await runGittensoryAiReview(env, { + ...baseInput, + mode: "block", + combine: "synthesis", + onMerge: "both", // matches a non-"either" floor: never clamped + }); + if (result.status !== "ok") throw new Error("expected ok"); + // Under "both", a single reviewer's blocker does NOT decide the outcome on its own. + expect(result.consensusDefect).toBeNull(); + expect(await renderMetrics()).not.toContain("gittensory_ai_review_onmerge_clamped_total"); + }); + + it("when the operator set no onMerge floor at all, any per-repo value is honored unclamped", async () => { + const env = planEnv( + { reviewers: [{ model: "claude-code" }, { model: "codex" }], combine: "synthesis" }, // no onMerge set + async (model) => + model === "codex" + ? { response: reviewJson({ present: true, title: "Lone blocker" }) } + : { response: reviewJson({ present: false }) }, + ); + const result = await runGittensoryAiReview(env, { + ...baseInput, + mode: "block", + combine: "synthesis", + onMerge: "both", // no floor to violate + }); + if (result.status !== "ok") throw new Error("expected ok"); + expect(result.consensusDefect).toBeNull(); // "both" honored: lone blocker does not decide + expect(await renderMetrics()).not.toContain("gittensory_ai_review_onmerge_clamped_total"); + }); + }); +}); + +describe("resolveEffectiveAiReviewOnMerge (#2567, pure precedence logic)", () => { + it("no repo override ⇒ the operator's floor (or null/undefined) passes through unclamped", () => { + expect(resolveEffectiveAiReviewOnMerge(null, "either")).toEqual({ onMerge: "either", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge(undefined, "both")).toEqual({ onMerge: "both", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge(undefined, undefined)).toEqual({ onMerge: undefined, clamped: false }); + expect(resolveEffectiveAiReviewOnMerge(null, null)).toEqual({ onMerge: null, clamped: false }); + }); + + it("a tightening or matching override (either -> either) always wins, never clamped", () => { + expect(resolveEffectiveAiReviewOnMerge("either", "either")).toEqual({ onMerge: "either", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge("either", "both")).toEqual({ onMerge: "either", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge("either", null)).toEqual({ onMerge: "either", clamped: false }); // no floor + expect(resolveEffectiveAiReviewOnMerge("either", undefined)).toEqual({ onMerge: "either", clamped: false }); // no floor + }); + + it("only an either-floor + both-override loosening attempt is clamped back to either", () => { + expect(resolveEffectiveAiReviewOnMerge("both", "either")).toEqual({ onMerge: "either", clamped: true }); + }); + + it("a both override against a both (or unset) floor is honored unclamped — there is no stricter floor to violate", () => { + expect(resolveEffectiveAiReviewOnMerge("both", "both")).toEqual({ onMerge: "both", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge("both", null)).toEqual({ onMerge: "both", clamped: false }); + expect(resolveEffectiveAiReviewOnMerge("both", undefined)).toEqual({ onMerge: "both", clamped: false }); + }); }); describe("pure helpers", () => { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 528d55d200..7a7c5f1261 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -496,7 +496,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, @@ -804,7 +804,7 @@ describe("parseFocusManifest gate config", () => { // the block→advisory deprecation-downgrade behavior itself is covered separately below. const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { @@ -1022,6 +1022,69 @@ describe("parseFocusManifest gate config", () => { expect(resolveEffectiveSettings({ aiReviewCloseConfidence: 0.6 } as unknown as RepositorySettings, noFlag).aiReviewCloseConfidence).toBe(0.6); }); + it("parses gate.aiReview.combine, makes the gate present, round-trips + resolves it, and warns on a bad value (#2567)", () => { + const m = parseFocusManifest({ gate: { aiReview: { combine: "synthesis" } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.aiReviewCombine).toBe("synthesis"); + expect((gateConfigToJson(m.gate) as { aiReview: { combine: string } }).aiReview.combine).toBe("synthesis"); + expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips + expect(parseFocusManifest({ gate: { aiReview: { combine: "loud" } } }).warnings.some((w) => /gate\.aiReview\.combine/.test(w))).toBe(true); + expect(parseFocusManifest({ gate: { aiReview: { combine: "loud" } } }).gate.aiReviewCombine).toBeNull(); + const eff = resolveEffectiveSettings({ aiReviewCombine: undefined } as unknown as RepositorySettings, m); + expect(eff.aiReviewCombine).toBe("synthesis"); + const noFlag = parseFocusManifest({ gate: { aiReview: { mode: "advisory" } } }); + expect(noFlag.gate.aiReviewCombine).toBeNull(); + // Absent ⇒ the resolver leaves the DB/default value untouched. + expect(resolveEffectiveSettings({ aiReviewCombine: "consensus" } as unknown as RepositorySettings, noFlag).aiReviewCombine).toBe("consensus"); + }); + + it("parses gate.aiReview.onMerge, makes the gate present, round-trips + resolves it, and warns on a bad value (#2567)", () => { + const m = parseFocusManifest({ gate: { aiReview: { onMerge: "both" } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.aiReviewOnMerge).toBe("both"); + expect((gateConfigToJson(m.gate) as { aiReview: { onMerge: string } }).aiReview.onMerge).toBe("both"); + expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips + expect(parseFocusManifest({ gate: { aiReview: { onMerge: "any" } } }).warnings.some((w) => /gate\.aiReview\.onMerge/.test(w))).toBe(true); + expect(parseFocusManifest({ gate: { aiReview: { onMerge: "any" } } }).gate.aiReviewOnMerge).toBeNull(); + // resolveEffectiveSettings projects the raw override unclamped — the operator-floor clamp itself is enforced + // downstream in services/ai-review.ts (resolveEffectiveAiReviewOnMerge), which this resolver cannot see. + const eff = resolveEffectiveSettings({ aiReviewOnMerge: undefined } as unknown as RepositorySettings, m); + expect(eff.aiReviewOnMerge).toBe("both"); + const noFlag = parseFocusManifest({ gate: { aiReview: { mode: "advisory" } } }); + expect(noFlag.gate.aiReviewOnMerge).toBeNull(); + expect(resolveEffectiveSettings({ aiReviewOnMerge: "either" } as unknown as RepositorySettings, noFlag).aiReviewOnMerge).toBe("either"); + }); + + it("parses gate.aiReview.reviewers, makes the gate present, round-trips + resolves it, caps entries, and drops invalid ones (#2567)", () => { + const m = parseFocusManifest({ gate: { aiReview: { reviewers: [{ model: "claude-code" }, { model: "codex", fallback: "ollama" }] } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.aiReviewReviewers).toEqual([{ model: "claude-code" }, { model: "codex", fallback: "ollama" }]); + expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips + const eff = resolveEffectiveSettings({ aiReviewReviewers: undefined } as unknown as RepositorySettings, m); + expect(eff.aiReviewReviewers).toEqual([{ model: "claude-code" }, { model: "codex", fallback: "ollama" }]); + // Absent ⇒ null ⇒ the DB/default value is left untouched. + const noFlag = parseFocusManifest({ gate: { aiReview: { mode: "advisory" } } }); + expect(noFlag.gate.aiReviewReviewers).toBeNull(); + expect(resolveEffectiveSettings({ aiReviewReviewers: [{ model: "existing" }] } as unknown as RepositorySettings, noFlag).aiReviewReviewers).toEqual([{ model: "existing" }]); + // Non-array ⇒ warns, stays null. + expect(parseFocusManifest({ gate: { aiReview: { reviewers: "claude-code" } } }).warnings.some((w) => /gate\.aiReview\.reviewers/.test(w))).toBe(true); + expect(parseFocusManifest({ gate: { aiReview: { reviewers: "claude-code" } } }).gate.aiReviewReviewers).toBeNull(); + // A non-mapping entry and a blank-model entry are dropped, but valid siblings survive. + const mixed = parseFocusManifest({ gate: { aiReview: { reviewers: [{ model: "claude-code" }, "nope", { model: " " }, { fallback: "x" }] } } }); + expect(mixed.gate.aiReviewReviewers).toEqual([{ model: "claude-code" }]); + expect(mixed.warnings.some((w) => /gate\.aiReview\.reviewers\[1\]/.test(w))).toBe(true); + expect(mixed.warnings.some((w) => /gate\.aiReview\.reviewers\[2\]\.model/.test(w))).toBe(true); + expect(mixed.warnings.some((w) => /gate\.aiReview\.reviewers\[3\]\.model/.test(w))).toBe(true); + // All-invalid list ⇒ null (not an empty array), matching every other manifest "absent means null" contract. + expect(parseFocusManifest({ gate: { aiReview: { reviewers: ["nope"] } } }).gate.aiReviewReviewers).toBeNull(); + // Over the cap: only the first 4 entries survive, with a warning. + const over = parseFocusManifest({ + gate: { aiReview: { reviewers: [{ model: "a" }, { model: "b" }, { model: "c" }, { model: "d" }, { model: "e" }] } }, + }); + expect(over.gate.aiReviewReviewers).toEqual([{ model: "a" }, { model: "b" }, { model: "c" }, { model: "d" }]); + expect(over.warnings.some((w) => /gate\.aiReview\.reviewers" is capped/.test(w))).toBe(true); + }); + it("parses the features: block (per-repo converged-feature toggles), round-trips it, and makes the manifest present", () => { const m = parseFocusManifest({ features: { rag: true, reputation: false, unifiedComment: true } }); expect(m.present).toBe(true);