diff --git a/.loopover.yml.example b/.loopover.yml.example index d27603ddaf..96c1c8d3e5 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -696,6 +696,14 @@ settings: # override that default in either direction for this repo. inherit | off | enabled. Default: inherit. # skipAutomationBotAuthors: inherit + # Duplicate-winner adjudication (#dup-winner): when several open PRs link the same + # issue, off (the legacy default) closes EVERY sibling as a duplicate; on spares the + # earliest claimant. "inherit" defers to the self-host operator's + # LOOPOVER_DUPLICATE_WINNER env default (itself off by default); "off"/"enabled" + # override that default in either direction for this repo. + # inherit | off | enabled. Default: inherit. + # duplicateWinnerMode: inherit + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 125203c2b6..d6830915af 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -8812,6 +8812,12 @@ "block" ] }, + "sizeGateMaxFiles": { + "type": "number" + }, + "sizeGateMaxLines": { + "type": "number" + }, "lockfileIntegrityGateMode": { "type": "string", "enum": [ @@ -8846,6 +8852,25 @@ "type": "string" } }, + "advisoryCheckRuns": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "appSlug": { + "type": "string" + } + }, + "required": [ + "name", + "appSlug" + ] + } + }, "copycatGateMode": { "type": "string", "enum": [ @@ -8941,6 +8966,10 @@ "aiReviewAllAuthors": { "type": "boolean" }, + "aiReviewConfirmedContributorsOnly": { + "type": "boolean", + "nullable": true + }, "aiReviewCloseConfidence": { "type": "number", "nullable": true @@ -9463,6 +9492,10 @@ "type": "string" } }, + "hardGuardrailGlobsOverridesInvariants": { + "type": "boolean", + "nullable": true + }, "manualReviewLabel": { "type": "string", "nullable": true @@ -9549,6 +9582,14 @@ "enabled" ] }, + "duplicateWinnerMode": { + "type": "string", + "enum": [ + "inherit", + "off", + "enabled" + ] + }, "reviewEvasionProtection": { "type": "string", "enum": [ @@ -9632,39 +9673,6 @@ "updatedAt": { "type": "string", "nullable": true - }, - "hardGuardrailGlobsOverridesInvariants": { - "type": "boolean", - "nullable": true - }, - "aiReviewConfirmedContributorsOnly": { - "type": "boolean", - "nullable": true - }, - "advisoryCheckRuns": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "appSlug": { - "type": "string" - } - }, - "required": [ - "name", - "appSlug" - ] - } - }, - "sizeGateMaxFiles": { - "type": "number" - }, - "sizeGateMaxLines": { - "type": "number" } }, "required": [ @@ -10341,6 +10349,10 @@ "aiReviewAllAuthors": { "type": "boolean" }, + "aiReviewConfirmedContributorsOnly": { + "type": "boolean", + "nullable": true + }, "commandAuthorization": { "type": "object", "properties": { @@ -10388,10 +10400,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "aiReviewConfirmedContributorsOnly": { - "type": "boolean", - "nullable": true } }, "required": [ @@ -10700,6 +10708,67 @@ "detailLevel" ] }, + "checkRunReadiness": { + "type": "object", + "nullable": true, + "properties": { + "readinessBand": { + "type": "string", + "enum": [ + "strong", + "developing", + "early" + ] + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "enum": [ + "traceability", + "related_work", + "change_scope", + "validation", + "pr_state", + "queue_pressure" + ] + }, + "label": { + "type": "string" + }, + "band": { + "type": "string", + "enum": [ + "met", + "partial", + "unmet" + ] + }, + "evidence": { + "type": "string" + }, + "action": { + "type": "string" + } + }, + "required": [ + "key", + "label", + "band", + "evidence", + "action" + ] + } + } + }, + "required": [ + "readinessBand", + "components" + ] + }, "installPreview": { "type": "object", "properties": { @@ -10882,67 +10951,6 @@ }, "summary": { "type": "string" - }, - "checkRunReadiness": { - "type": "object", - "nullable": true, - "properties": { - "readinessBand": { - "type": "string", - "enum": [ - "strong", - "developing", - "early" - ] - }, - "components": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "enum": [ - "traceability", - "related_work", - "change_scope", - "validation", - "pr_state", - "queue_pressure" - ] - }, - "label": { - "type": "string" - }, - "band": { - "type": "string", - "enum": [ - "met", - "partial", - "unmet" - ] - }, - "evidence": { - "type": "string" - }, - "action": { - "type": "string" - } - }, - "required": [ - "key", - "label", - "band", - "evidence", - "action" - ] - } - } - }, - "required": [ - "readinessBand", - "components" - ] } }, "required": [ @@ -14353,6 +14361,16 @@ }, "/v1/installations/{id}/health": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { "description": "GitHub App installation health", @@ -14375,7 +14393,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/installations/{id}/repair": { + "get": { "parameters": [ { "schema": { @@ -14385,11 +14407,7 @@ "name": "id", "in": "path" } - ] - } - }, - "/v1/installations/{id}/repair": { - "get": { + ], "responses": { "200": { "description": "GitHub App installation repair diagnostics", @@ -14412,7 +14430,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/installations/{id}/repair/refresh": { + "post": { "parameters": [ { "schema": { @@ -14422,11 +14444,7 @@ "name": "id", "in": "path" } - ] - } - }, - "/v1/installations/{id}/repair/refresh": { - "post": { + ], "responses": { "200": { "description": "Refreshed GitHub App installation repair diagnostics", @@ -14449,16 +14467,6 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } ] } }, @@ -14630,15 +14638,33 @@ }, "/v1/repos/{owner}/{repo}": { "get": { - "responses": { - "200": { - "description": "Repository detail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Repository" - } - } + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Repository detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Repository" + } + } } }, "404": { @@ -14652,7 +14678,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/intelligence": { + "get": { "parameters": [ { "schema": { @@ -14670,11 +14700,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/intelligence": { - "get": { + ], "responses": { "200": { "description": "Canonical repository intelligence bundle", @@ -14694,7 +14720,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/issue-quality": { + "get": { "parameters": [ { "schema": { @@ -14712,11 +14742,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/issue-quality": { - "get": { + ], "responses": { "200": { "description": "Cached or computed issue quality report for the repo", @@ -14739,7 +14765,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/outcome-patterns": { + "get": { "parameters": [ { "schema": { @@ -14757,11 +14787,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/outcome-patterns": { - "get": { + ], "responses": { "200": { "description": "Cached or freshly-computed per-repo accepted/rejected PR outcome patterns with freshness envelope and explicit evidence-completeness", @@ -14784,7 +14810,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/registration-readiness": { + "get": { "parameters": [ { "schema": { @@ -14802,11 +14832,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/registration-readiness": { - "get": { + ], "responses": { "200": { "description": "Gittensor registration readiness signal for repo owners", @@ -14826,7 +14852,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/gittensor-config-recommendation": { + "get": { "parameters": [ { "schema": { @@ -14844,11 +14874,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/gittensor-config-recommendation": { - "get": { + ], "responses": { "200": { "description": "Private Gittensor config recommendation for repo owners", @@ -14868,7 +14894,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/focus-manifest": { + "get": { "parameters": [ { "schema": { @@ -14886,11 +14916,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest": { - "get": { + ], "responses": { "200": { "description": "Repo focus manifest and compiled policy for maintainers", @@ -14916,7 +14942,9 @@ { "LoopOverSessionCookie": [] } - ], + ] + }, + "put": { "parameters": [ { "schema": { @@ -14934,9 +14962,7 @@ "name": "repo", "in": "path" } - ] - }, - "put": { + ], "responses": { "200": { "description": "Persist API-backed focus manifest for a repo", @@ -14965,7 +14991,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { + "post": { "parameters": [ { "schema": { @@ -14983,11 +15013,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { - "post": { + ], "responses": { "200": { "description": "Refresh the persisted focus manifest cache from the repo file", @@ -15013,7 +15039,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/agent/audit-feed": { + "get": { "parameters": [ { "schema": { @@ -15031,11 +15061,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/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. ?pull=N opts into the unfiltered sibling query: every audit_events row for that one PR's targetKey (no eventType restriction), still maintainer-gated and detail-sanitized the same way.", @@ -15159,24 +15185,240 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } + ] + } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "severity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "mergedSha": { + "type": "string" + } + }, + "required": [ + "description", + "severity" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Post-merge incident report recorded as an audit_events row (#5672), customer-facing (repo maintainer) side", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "ok", + "repoFullName", + "pullNumber", + "id", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid pull number or incident report body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Pull request not found" + }, + "409": { + "description": "Pull request has not been merged" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/app/incident-reports": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "pullNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "severity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "mergedSha": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "pullNumber", + "description", + "severity" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Post-merge incident report recorded as an audit_events row (#5672), internal-operator side", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "ok", + "repoFullName", + "pullNumber", + "id", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid incident report body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Pull request not found" + }, + "409": { + "description": "Pull request has not been merged" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } ] } }, @@ -15212,6 +15454,24 @@ }, "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], "responses": { "200": { "description": "Private self-dogfood registration pack when repo matches configured LoopOver target", @@ -15237,7 +15497,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { + "get": { "parameters": [ { "schema": { @@ -15255,11 +15519,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { - "get": { + ], "responses": { "200": { "description": "Preview-only repo onboarding pack for accepted repositories", @@ -15288,7 +15548,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { "parameters": [ { "schema": { @@ -15306,11 +15570,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/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)", @@ -15339,7 +15599,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/settings": { + "get": { "parameters": [ { "schema": { @@ -15357,11 +15621,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/settings": { - "get": { + ], "responses": { "200": { "description": "LoopOver repository automation settings", @@ -15381,7 +15641,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/settings-preview": { + "post": { "parameters": [ { "schema": { @@ -15399,11 +15663,7 @@ "name": "repo", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/settings-preview": { - "post": { + ], "responses": { "200": { "description": "Maintainer dry-run preview of the public surface decision for a sample PR (no GitHub mutation)", @@ -15426,7 +15686,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet": { + "get": { "parameters": [ { "schema": { @@ -15443,12 +15707,16 @@ "required": true, "name": "repo", "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet": { - "get": { + ], "responses": { "200": { "description": "PR-specific maintainer review packet", @@ -15468,7 +15736,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability": { + "get": { "parameters": [ { "schema": { @@ -15494,11 +15766,7 @@ "name": "number", "in": "path" } - ] - } - }, - "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability": { - "get": { + ], "responses": { "200": { "description": "Private PR reviewability score and maintainer action", @@ -15518,37 +15786,21 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/contributors/{login}/profile": { + "get": { "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "number", + "name": "login", "in": "path" } - ] - } - }, - "/v1/contributors/{login}/profile": { - "get": { + ], "responses": { "200": { "description": "Contributor evidence profile", @@ -15568,7 +15820,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/contributors/{login}/decision-pack": { + "get": { "parameters": [ { "schema": { @@ -15578,11 +15834,7 @@ "name": "login", "in": "path" } - ] - } - }, - "/v1/contributors/{login}/decision-pack": { - "get": { + ], "responses": { "200": { "description": "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", @@ -15612,7 +15864,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/contributors/{login}/open-pr-monitor": { + "get": { "parameters": [ { "schema": { @@ -15622,11 +15878,7 @@ "name": "login", "in": "path" } - ] - } - }, - "/v1/contributors/{login}/open-pr-monitor": { - "get": { + ], "responses": { "200": { "description": "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", @@ -15646,7 +15898,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/contributors/{login}/repos/{owner}/{repo}/decision": { + "get": { "parameters": [ { "schema": { @@ -15655,12 +15911,24 @@ "required": true, "name": "login", "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" } - ] - } - }, - "/v1/contributors/{login}/repos/{owner}/{repo}/decision": { - "get": { + ], "responses": { "200": { "description": "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", @@ -15690,32 +15958,6 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "login", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } ] } }, @@ -15897,6 +16139,16 @@ }, "/v1/agent/runs/{id}": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { "description": "Persisted agent run bundle", @@ -15919,16 +16171,6 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } ] } }, @@ -16121,6 +16363,16 @@ }, "/v1/bounties/{id}/advisory": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { "description": "Bounty lifecycle advisory", @@ -16143,7 +16395,11 @@ { "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/bounties/{id}/lifecycle": { + "get": { "parameters": [ { "schema": { @@ -16153,11 +16409,7 @@ "name": "id", "in": "path" } - ] - } - }, - "/v1/bounties/{id}/lifecycle": { - "get": { + ], "responses": { "200": { "description": "Bounty lifecycle transition history", @@ -16180,16 +16432,6 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } ] } }, @@ -17544,240 +17786,6 @@ } ] } - }, - "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "number", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - }, - "severity": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ] - }, - "mergedSha": { - "type": "string" - } - }, - "required": [ - "description", - "severity" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Post-merge incident report recorded as an audit_events row (#5672), customer-facing (repo maintainer) side", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] - }, - "repoFullName": { - "type": "string" - }, - "pullNumber": { - "type": "number" - }, - "id": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "ok", - "repoFullName", - "pullNumber", - "id", - "createdAt" - ] - } - } - } - }, - "400": { - "description": "Invalid pull number or incident report body" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient role" - }, - "404": { - "description": "Pull request not found" - }, - "409": { - "description": "Pull request has not been merged" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ] - } - }, - "/v1/app/incident-reports": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string", - "minLength": 3, - "maxLength": 200 - }, - "pullNumber": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - }, - "severity": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ] - }, - "mergedSha": { - "type": "string" - } - }, - "required": [ - "repoFullName", - "pullNumber", - "description", - "severity" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Post-merge incident report recorded as an audit_events row (#5672), internal-operator side", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] - }, - "repoFullName": { - "type": "string" - }, - "pullNumber": { - "type": "number" - }, - "id": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "ok", - "repoFullName", - "pullNumber", - "id", - "createdAt" - ] - } - } - } - }, - "400": { - "description": "Invalid incident report body" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, - "404": { - "description": "Pull request not found" - }, - "409": { - "description": "Pull request has not been merged" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ] - } } }, "servers": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 576f72a92b..5a45075927 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -710,6 +710,14 @@ settings: # override that default in either direction for this repo. inherit | off | enabled. Default: inherit. # skipAutomationBotAuthors: inherit + # Duplicate-winner adjudication (#dup-winner): when several open PRs link the same + # issue, off (the legacy default) closes EVERY sibling as a duplicate; on spares the + # earliest claimant. "inherit" defers to the self-host operator's + # LOOPOVER_DUPLICATE_WINNER env default (itself off by default); "off"/"enabled" + # override that default in either direction for this repo. + # inherit | off | enabled. Default: inherit. + # duplicateWinnerMode: inherit + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index e1fa455dbf..f0d880391b 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -400,6 +400,7 @@ export type FocusManifestSettings = Partial< | "aiReviewConfirmedContributorsOnly" | "closeOwnerAuthors" | "skipAutomationBotAuthors" + | "duplicateWinnerMode" | "autoLabelEnabled" | "typeLabelsEnabled" | "badgeEnabled" @@ -2238,6 +2239,10 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) // DB/dashboard-write-only despite RepositorySettings already carrying the field; this closes that gap. const skipAutomationBotAuthors = normalizeOptionalEnum(r.skipAutomationBotAuthors, "settings.skipAutomationBotAuthors", ["inherit", "off", "enabled"] as const, warnings); if (skipAutomationBotAuthors !== null) out.skipAutomationBotAuthors = skipAutomationBotAuthors; + // Duplicate-winner adjudication (#dup-winner): per-repo override of the global LOOPOVER_DUPLICATE_WINNER + // default -- "inherit" defers to it, "off"/"enabled" override in either direction for this repo. + const duplicateWinnerMode = normalizeOptionalEnum(r.duplicateWinnerMode, "settings.duplicateWinnerMode", ["inherit", "off", "enabled"] as const, warnings); + if (duplicateWinnerMode !== null) out.duplicateWinnerMode = duplicateWinnerMode; // Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config. const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"] as const, warnings); if (moderationGateMode !== null) out.moderationGateMode = moderationGateMode; diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index 0ba136aa82..511bd27ac8 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -348,6 +348,12 @@ export type RepositorySettings = { * self-host operator's `LOOPOVER_SKIP_AUTOMATION_BOT_PRS` global default (itself default-ON); `"off"`/ * `"enabled"` override that default in either direction for this repo. */ skipAutomationBotAuthors?: "inherit" | "off" | "enabled" | undefined; + /** Duplicate-winner adjudication (#dup-winner): when several OPEN PRs link the same issue, the legacy + * behavior closes EVERY sibling as a duplicate. With this active, exactly one winner (the earliest + * claimant) is spared. `"inherit"` defers to the `LOOPOVER_DUPLICATE_WINNER` global env default (itself + * default-OFF); `"off"`/`"enabled"` fully override the global default in either direction for this repo. No + * DB column -- config-as-code only, set via `.loopover.yml settings.duplicateWinnerMode`. */ + duplicateWinnerMode?: "inherit" | "off" | "enabled" | undefined; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/src/api/routes.ts b/src/api/routes.ts index f8d3f61bc9..9d69ff212c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -30,6 +30,7 @@ import { import { normalizeGittBountySnapshot } from "../bounties/ingest"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; import { normalizeContributorBlacklist } from "../settings/contributor-blacklist"; +import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { countOpenIssues, @@ -2604,7 +2605,16 @@ export function createApp() { getRepositorySettings(c.env, fullName), listPullRequests(c.env, fullName), ]); - return c.json(buildMaintainerActivationPreview({ repoFullName: fullName, repo, settings, pullRequests, generatedAt: nowIso(), duplicateWinnerEnabled: c.env.LOOPOVER_DUPLICATE_WINNER === "true" })); + return c.json( + buildMaintainerActivationPreview({ + repoFullName: fullName, + repo, + settings, + pullRequests, + generatedAt: nowIso(), + duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(c.env), settings.duplicateWinnerMode), + }), + ); }); // #543 outcome-learning loop: is the slop score predictive, and are recommendations panning out? Read-only diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index efea7af2fb..e4a7b1db06 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -847,6 +847,7 @@ export const RepositorySettingsSchema = z moderationWarningLabel: z.string().optional(), moderationBannedLabel: z.string().optional(), skipAutomationBotAuthors: z.enum(["inherit", "off", "enabled"]).optional(), + duplicateWinnerMode: z.enum(["inherit", "off", "enabled"]).optional(), reviewEvasionProtection: z .enum(["off", "close"]) .optional() diff --git a/src/queue/duplicate-detection.ts b/src/queue/duplicate-detection.ts index bcbb8b2012..0afacc5090 100644 --- a/src/queue/duplicate-detection.ts +++ b/src/queue/duplicate-detection.ts @@ -10,7 +10,8 @@ import { createInstallationToken } from "../github/app"; import { fetchLivePullRequestState } from "../github/backfill"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; import { isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "../signals/duplicate-winner"; -import type { PullRequestRecord } from "../types"; +import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; +import type { PullRequestRecord, RepositorySettings } from "../types"; /** * Duplicate-winner adjudication (#dup-winner) seam for the close-reason disposition. Given a PR's open @@ -72,8 +73,9 @@ export async function reconcileLiveDuplicateSiblings( repoFullName: string, pr: PullRequestRecord, otherOpenPullRequests: PullRequestRecord[], + settings: Pick, ): Promise { - if (env.LOOPOVER_DUPLICATE_WINNER !== "true") return otherOpenPullRequests; + if (!resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode)) return otherOpenPullRequests; const linkedIssues = new Set(pr.linkedIssues); if (linkedIssues.size === 0) return otherOpenPullRequests; const overlapping = otherOpenPullRequests.filter( diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 151cc1cdf3..cb786fd66e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -314,6 +314,7 @@ import { type ContributorProfile, } from "../signals/engine"; import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner"; +import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; import { buildAiReviewDiff, buildSecretScanDiff, buildUnifiedReviewDiff, totalAddedLineCount } from "../review/review-diff"; // #4013 step 4 (prep): buildAiReviewDiff/buildSecretScanDiff moved to review-diff.ts (a natural existing // home -- both already wrapped buildUnifiedReviewDiff there) rather than staying here, since keeping them @@ -1464,7 +1465,7 @@ export async function sweepRepoRegate( const verdicts: Record = {}; const flaggedPulls: number[] = []; const sweepInstallationId = repo?.installationId ?? null; - const duplicateWinnerEnabled = env.LOOPOVER_DUPLICATE_WINNER === "true"; + const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); // #selfhost-queue-liveness: priorityPullNumbers (surfaceRepairPriorityPullNumbers, above) are OUTAGE REPAIR -- // a PR with no current-head Gate check or an unpublished current-head surface -- not routine staleness. A // repair candidate's fanned-out job must NOT carry the "regate-sweep:" deliveryId prefix, or @@ -2892,7 +2893,7 @@ async function runAgentMaintenancePlanAndExecute( const approvalsSatisfied = autoMaintain.requireApprovals === 0 || (liveReviewDecision ?? pr.reviewDecision) === "APPROVED"; - const duplicateWinnerEnabled = env.LOOPOVER_DUPLICATE_WINNER === "true"; + const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); const openDuplicateSiblings = linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests); // AI-review low-confidence guardrail (#4603): resolved PURELY from this pass's own gate evaluation + settings // (no extra network/DB call, unlike migrationCollisionHold/unlinkedIssueMatchHold above) -- undefined unless the @@ -3317,11 +3318,12 @@ export async function reReviewStoredPullRequest( repoFullName, pr, cachedOtherOpenPullRequests, + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), - duplicateWinnerEnabled: env.LOOPOVER_DUPLICATE_WINNER === "true", + duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); @@ -5753,11 +5755,12 @@ async function handlePullRequestWebhookEvent( repoFullName, pr, cachedOtherOpenPullRequests, + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), - duplicateWinnerEnabled: env.LOOPOVER_DUPLICATE_WINNER === "true", + duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); @@ -8420,7 +8423,7 @@ async function maybePublishPrPublicSurface( pr, otherOpenPullRequests, ); - const duplicateWinnerEnabled = env.LOOPOVER_DUPLICATE_WINNER === "true"; + const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); const isDupWinner = duplicateWinnerEnabled && isDuplicateClusterWinnerByClaim(pr, linkedDuplicatePrsForGate); @@ -9930,7 +9933,7 @@ async function maybePublishPrPublicSurface( // Duplicate-winner adjudication (#dup-winner): thread the flag into the public panel builders so the // winner's hard-duplicate block is suppressed (they recompute the winner from their own open-only sibling // list). Flag-OFF (default) ⇒ false ⇒ the panels are byte-identical to today. - const duplicateWinnerEnabled = env.LOOPOVER_DUPLICATE_WINNER === "true"; + const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); // improvementSignal deterministic tier (#4742/#4744): pure/sync, no AI dependency, so it is computed // independent of aiReview's own eligibility gates above (a paused repo, non-reviewable author, or // aiReviewMode: "off" still gets this tier -- the two tiers are deliberately independent, epic #4737). @@ -12006,6 +12009,7 @@ export async function buildAuthorizedPrActionAdvisory( repoFullName, pr, cachedOtherOpenPullRequests, + settings, ); // Mirror the main webhook path: thread linked-issue authors + the open-reference check so an authorized PR // action (gate-override / panel retrigger) honors the same self-authored-linked-issue block AND stale- @@ -12020,7 +12024,7 @@ export async function buildAuthorizedPrActionAdvisory( const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), - duplicateWinnerEnabled: env.LOOPOVER_DUPLICATE_WINNER === "true", + duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); diff --git a/src/settings/duplicate-winner-mode.ts b/src/settings/duplicate-winner-mode.ts new file mode 100644 index 0000000000..164edf862a --- /dev/null +++ b/src/settings/duplicate-winner-mode.ts @@ -0,0 +1,20 @@ +export type DuplicateWinnerMode = "inherit" | "off" | "enabled"; + +/** Truthy convention matches the rest of this codebase's `LOOPOVER_*` flags (exact `"true"` string, e.g. the + * raw checks this replaces) -- unlike `isSkipAutomationBotPullRequestsEnabledGlobally` (default ON, inverted + * truthy match), this flag is opt-in and default OFF: sparing a duplicate cluster's earliest claimant is a + * real behavior change to the close disposition, not a low-risk waste-elimination default. */ +export function isDuplicateWinnerEnabledGlobally(env: { LOOPOVER_DUPLICATE_WINNER?: string | undefined }): boolean { + return env.LOOPOVER_DUPLICATE_WINNER === "true"; +} + +/** Per-repo override resolved against the global default. Mirrors `resolveSkipAutomationBotPullRequests`'s + * inherit/off/enabled shape (settings/automation-bot-skip.ts) -- symmetric: "off" and "enabled" both fully + * override the global default in either direction, so a repo opting IN is never blocked by a globally-off + * default, and a repo opting OUT keeps the legacy "every sibling closes" behavior even when the fleet default + * is on. */ +export function resolveDuplicateWinnerEnabled(globalDefault: boolean, mode: DuplicateWinnerMode | null | undefined): boolean { + if (mode === "off") return false; + if (mode === "enabled") return true; + return globalDefault; +} diff --git a/src/types.ts b/src/types.ts index 54522d5521..4caeae0189 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1194,6 +1194,14 @@ export type RepositorySettings = { * override the global default in either direction for this repo. Always populated by the DB layer; * optional so existing settings fixtures/callers need not be touched. */ skipAutomationBotAuthors?: "inherit" | "off" | "enabled" | undefined; + /** Duplicate-winner adjudication (#dup-winner): when several OPEN PRs link the same issue, the legacy + * behavior closes EVERY sibling as a duplicate. With this active, exactly one winner (the earliest + * claimant) is spared. `"inherit"` (the default, mirrors moderationGateMode/skipAutomationBotAuthors' + * shape) defers to the `LOOPOVER_DUPLICATE_WINNER` global env default (itself default-OFF -- see + * settings/duplicate-winner-mode.ts's own doc comment); `"off"`/`"enabled"` fully override the global + * default in either direction for this repo. No DB column -- config-as-code only, set via `.loopover.yml + * settings.duplicateWinnerMode`. */ + duplicateWinnerMode?: "inherit" | "off" | "enabled" | undefined; /** Review-evasion protection (#review-evasion-protection): a contributor closing or converting their OWN * PR to draft while loopover has an ACTIVE review pass running against it is dodging the one-shot * review process. The effective default is `"close"` as of #4011 (see `normalizeReviewEvasionProtection` diff --git a/test/unit/duplicate-winner-mode.test.ts b/test/unit/duplicate-winner-mode.test.ts new file mode 100644 index 0000000000..7bbdf65cb2 --- /dev/null +++ b/test/unit/duplicate-winner-mode.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../../src/settings/duplicate-winner-mode"; + +describe("isDuplicateWinnerEnabledGlobally", () => { + it("defaults OFF when unset", () => { + expect(isDuplicateWinnerEnabledGlobally({})).toBe(false); + expect(isDuplicateWinnerEnabledGlobally({ LOOPOVER_DUPLICATE_WINNER: undefined })).toBe(false); + expect(isDuplicateWinnerEnabledGlobally({ LOOPOVER_DUPLICATE_WINNER: "" })).toBe(false); + }); + + it("is ON only for the exact string \"true\"", () => { + expect(isDuplicateWinnerEnabledGlobally({ LOOPOVER_DUPLICATE_WINNER: "true" })).toBe(true); + }); + + it("stays OFF for any other value, including truthy-looking ones", () => { + for (const value of ["1", "yes", "on", "True", "TRUE", " true "]) { + expect(isDuplicateWinnerEnabledGlobally({ LOOPOVER_DUPLICATE_WINNER: value })).toBe(false); + } + }); +}); + +describe("resolveDuplicateWinnerEnabled", () => { + it("inherit defers to the global default in both directions", () => { + expect(resolveDuplicateWinnerEnabled(true, "inherit")).toBe(true); + expect(resolveDuplicateWinnerEnabled(false, "inherit")).toBe(false); + }); + + it("null/undefined mode behaves the same as inherit", () => { + expect(resolveDuplicateWinnerEnabled(true, null)).toBe(true); + expect(resolveDuplicateWinnerEnabled(false, undefined)).toBe(false); + }); + + it("off fully overrides a globally-ON default", () => { + expect(resolveDuplicateWinnerEnabled(true, "off")).toBe(false); + }); + + it("enabled fully overrides a globally-OFF default (symmetric)", () => { + expect(resolveDuplicateWinnerEnabled(false, "enabled")).toBe(true); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index c39f871368..73f0f32e2a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -309,6 +309,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { autoProjectMilestoneMatch: "autoProjectMilestoneMatch:", autoProjectMilestoneMatchBackend: "autoProjectMilestoneMatchBackend:", closeOwnerAuthors: "closeOwnerAuthors:", + duplicateWinnerMode: "duplicateWinnerMode:", autoLabelEnabled: "autoLabelEnabled:", typeLabelsEnabled: "typeLabelsEnabled:", badgeEnabled: "badgeEnabled:", @@ -2516,6 +2517,21 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(invalid.warnings.some((w) => /settings\.skipAutomationBotAuthors/.test(w))).toBe(true); }); + it("parses + resolves duplicateWinnerMode from the settings: block, overlaying the DB (#dup-winner)", () => { + const manifest = parseFocusManifest({ settings: { duplicateWinnerMode: "enabled" } }); + expect(manifest.settings.duplicateWinnerMode).toBe("enabled"); + // yml overlays (replaces) the DB-configured value. + const eff = resolveEffectiveSettings({ duplicateWinnerMode: "off" } as unknown as RepositorySettings, manifest); + expect(eff.duplicateWinnerMode).toBe("enabled"); + // Omitted in yml ⇒ the DB-configured value survives untouched. + const noOverride = resolveEffectiveSettings({ duplicateWinnerMode: "off" } as unknown as RepositorySettings, parseFocusManifest({})); + expect(noOverride.duplicateWinnerMode).toBe("off"); + // An invalid enum is dropped with a warning rather than silently coerced. + const invalid = parseFocusManifest({ settings: { duplicateWinnerMode: "sometimes" as never } }); + expect(invalid.settings.duplicateWinnerMode).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.duplicateWinnerMode/.test(w))).toBe(true); + }); + it("moderationRules accepts review_evasion alongside the original three rule types (#review-evasion-protection)", () => { const manifest = parseFocusManifest({ settings: { moderationRules: ["review_evasion", "not-a-rule" as never] } }); expect(manifest.settings.moderationRules).toEqual(["review_evasion"]); diff --git a/test/unit/queue-lifecycle-guards.test.ts b/test/unit/queue-lifecycle-guards.test.ts index 76dc0ee73d..d0bd822f51 100644 --- a/test/unit/queue-lifecycle-guards.test.ts +++ b/test/unit/queue-lifecycle-guards.test.ts @@ -3692,7 +3692,7 @@ describe("installation app_id capture + dual-app webhook filter (#selfhost-app-i const winner: Parameters[3] = { repoFullName: "owner/repo", number: 10, title: "Winner", state: "open", labels: [], linkedIssues: [1] }; const sibling: Parameters[3] = { repoFullName: "owner/repo", number: 5, title: "Sibling", state: "open", labels: [], linkedIssues: [1] }; - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", winner, [sibling]); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", winner, [sibling], { duplicateWinnerMode: undefined }); // The sibling is correctly dropped as stale-closed, proving a genuine live fetch happened rather than // trusting the warm-but-wrong cached "open" value. diff --git a/test/unit/reconcile-live-duplicate-siblings.test.ts b/test/unit/reconcile-live-duplicate-siblings.test.ts index f8036cd6b1..48803e3a19 100644 --- a/test/unit/reconcile-live-duplicate-siblings.test.ts +++ b/test/unit/reconcile-live-duplicate-siblings.test.ts @@ -16,6 +16,10 @@ function makePr(number: number, state: string, linkedIssues: number[]): PullRequ return { repoFullName: "owner/repo", number, title: `PR ${number}`, state, labels: [], linkedIssues }; } +// "inherit" (no per-repo override) preserves every existing test's semantics below unchanged -- the flag alone +// governs, exactly as before duplicateWinnerMode existed. +const settings = { duplicateWinnerMode: undefined }; + /** Stub fetch so any /pulls/{n} returns the mapped live state; an unmapped path 404s (→ undefined live state). */ function stubLiveStates(states: Record): void { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { @@ -44,7 +48,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { }); const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings)).toBe(siblings); + expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings)).toBe(siblings); }); it("flag ON but the PR links no issue ⇒ unchanged (no cluster to adjudicate)", async () => { @@ -52,7 +56,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { env.LOOPOVER_DUPLICATE_WINNER = "true"; const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", []); - expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings)).toBe(siblings); + expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings)).toBe(siblings); }); it("flag ON, a higher overlapping sibling LIVE-closed ⇒ dropped because claim-time election lets higher numbers demote", async () => { @@ -61,7 +65,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 12: "closed" }); const siblings = [makePr(12, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([]); }); @@ -71,7 +75,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 12: "open" }); const siblings = [makePr(12, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([12]); }); @@ -80,7 +84,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { env.LOOPOVER_DUPLICATE_WINNER = "true"; const siblings = [makePr(12, "open", [2])]; const pr = makePr(9, "open", [1]); - expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings)).toBe(siblings); + expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings)).toBe(siblings); }); it("flag ON, a sibling already cached non-open ⇒ unchanged (the cache already excludes it)", async () => { @@ -88,7 +92,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { env.LOOPOVER_DUPLICATE_WINNER = "true"; const siblings = [makePr(5, "closed", [1])]; const pr = makePr(9, "open", [1]); - expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings)).toBe(siblings); + expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings)).toBe(siblings); }); it("flag ON, a lower overlapping sibling LIVE-closed ⇒ dropped so the remaining open cluster is adjudicated from live members", async () => { @@ -97,7 +101,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 5: "closed" }); const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([]); }); @@ -107,7 +111,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 5: "open" }); const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([5]); }); @@ -117,7 +121,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({}); // every /pulls/{n} 404s ⇒ undefined live state const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([5]); }); @@ -127,7 +131,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 4: "closed", 12: "open" }); const siblings = [makePr(4, "open", [1]), makePr(12, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, settings); expect(result.map((p) => p.number)).toEqual([12]); }); @@ -138,7 +142,7 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 5: "closed" }); const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, 4242, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, 4242, "owner/repo", pr, siblings, settings); expect(mockedToken).toHaveBeenCalledWith(env, 4242); expect(result.map((p) => p.number)).toEqual([]); }); @@ -150,8 +154,29 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { stubLiveStates({ 5: "closed" }); const siblings = [makePr(5, "open", [1])]; const pr = makePr(9, "open", [1]); - const result = await reconcileLiveDuplicateSiblings(env, 4242, "owner/repo", pr, siblings); + const result = await reconcileLiveDuplicateSiblings(env, 4242, "owner/repo", pr, siblings, settings); expect(mockedToken).toHaveBeenCalledWith(env, 4242); expect(result.map((p) => p.number)).toEqual([]); }); + + it("regression: a per-repo duplicateWinnerMode: \"enabled\" override reconciles even when the global flag is off", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "false"; + stubLiveStates({ 5: "closed" }); + const siblings = [makePr(5, "open", [1])]; + const pr = makePr(9, "open", [1]); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { duplicateWinnerMode: "enabled" }); + expect(result.map((p) => p.number)).toEqual([]); + }); + + it("regression: a per-repo duplicateWinnerMode: \"off\" override returns the cached list untouched even when the global flag is on", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "true"; + vi.stubGlobal("fetch", async () => { + throw new Error("fetch must not be called when the repo has opted out"); + }); + const siblings = [makePr(5, "open", [1])]; + const pr = makePr(9, "open", [1]); + expect(await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { duplicateWinnerMode: "off" })).toBe(siblings); + }); });