diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 1387544a3e..c40aef7557 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3619,18 +3619,6 @@ "enabled" ] }, - "quietByDefault": { - "type": "boolean" - }, - "behavior": { - "type": "string" - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } - }, "reviewCheckMode": { "type": "string", "enum": [ @@ -3653,6 +3641,18 @@ "github", "linear" ] + }, + "quietByDefault": { + "type": "boolean" + }, + "behavior": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -8497,6 +8497,29 @@ "enabled" ] }, + "reviewCheckMode": { + "type": "string", + "enum": [ + "required", + "visible", + "disabled" + ] + }, + "autoProjectMilestoneMatch": { + "type": "string", + "enum": [ + "off", + "suggest", + "auto" + ] + }, + "autoProjectMilestoneMatchBackend": { + "type": "string", + "enum": [ + "github", + "linear" + ] + }, "gatePack": { "type": "string", "enum": [ @@ -8556,6 +8579,32 @@ "block" ] }, + "claGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "claConsentPhrase": { + "type": "string", + "nullable": true + }, + "claCheckRunName": { + "type": "string", + "nullable": true + }, + "claCheckRunAppSlug": { + "type": "string", + "nullable": true + }, + "expectedCiContexts": { + "type": "array", + "items": { + "type": "string" + } + }, "gateDryRun": { "type": "boolean" }, @@ -8677,6 +8726,144 @@ "autoLabelEnabled": { "type": "boolean" }, + "typeLabelsEnabled": { + "type": "boolean" + }, + "typeLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "linkedIssueLabelPropagation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "exclusive_type_label" + ] + }, + "mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueLabel": { + "type": "string" + }, + "prLabel": { + "type": "string" + }, + "removeOtherTypeLabels": { + "type": "boolean" + } + }, + "required": [ + "issueLabel", + "prLabel", + "removeOtherTypeLabels" + ] + } + } + }, + "required": [ + "enabled", + "mode", + "mappings" + ] + }, + "linkedIssueHardRules": { + "type": "object", + "properties": { + "ownerAssignedClose": { + "type": "string", + "enum": [ + "block", + "off" + ] + }, + "assignedIssueClose": { + "type": "string", + "enum": [ + "block", + "off" + ] + }, + "missingPointLabelClose": { + "type": "string", + "enum": [ + "block", + "off" + ] + }, + "maintainerOnlyLabelClose": { + "type": "string", + "enum": [ + "block", + "off" + ] + }, + "pointBearingLabels": { + "type": "array", + "items": { + "type": "string" + } + }, + "maintainerOnlyLabels": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaultLabelRepo": { + "type": "boolean" + }, + "verifyBeforeClose": { + "type": "boolean" + }, + "closeDelaySeconds": { + "type": "integer", + "minimum": 0, + "maximum": 300 + } + }, + "required": [ + "ownerAssignedClose", + "assignedIssueClose", + "missingPointLabelClose", + "maintainerOnlyLabelClose", + "pointBearingLabels", + "maintainerOnlyLabels", + "defaultLabelRepo", + "verifyBeforeClose", + "closeDelaySeconds" + ] + }, + "unlinkedIssueGuardrail": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "hold", + "off" + ] + }, + "minConfidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "mode", + "minConfidence" + ] + }, "gittensorLabel": { "type": "string" }, @@ -8910,6 +9097,10 @@ "type": "string", "nullable": true }, + "contributorCapCancelCi": { + "type": "boolean", + "nullable": true + }, "reviewNagPolicy": { "type": "string", "enum": [ @@ -8933,12 +9124,45 @@ "type": "string", "nullable": true }, + "reviewNagMonitoredMentions": { + "type": "array", + "items": { + "type": "string" + } + }, "autoCloseExemptLogins": { "type": "array", "items": { "type": "string" } }, + "hardGuardrailGlobs": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "manualReviewLabel": { + "type": "string", + "nullable": true + }, + "readyToMergeLabel": { + "type": "string", + "nullable": true + }, + "changesRequestedLabel": { + "type": "string", + "nullable": true + }, + "migrationCollisionLabel": { + "type": "string", + "nullable": true + }, + "pendingClosureLabel": { + "type": "string", + "nullable": true + }, "accountAgeThresholdDays": { "type": "integer", "nullable": true, @@ -8970,50 +9194,13 @@ "minimum": 0, "exclusiveMinimum": true }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { + "moderationGateMode": { "type": "string", - "nullable": true - }, - "reviewNagMonitoredMentions": { - "type": "array", - "items": { - "type": "string" - } - }, - "claGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "claConsentPhrase": { - "type": "string", - "nullable": true - }, - "claCheckRunName": { - "type": "string", - "nullable": true - }, - "contributorCapCancelCi": { - "type": "boolean", - "nullable": true - }, - "typeLabelsEnabled": { - "type": "boolean" - }, - "moderationGateMode": { - "type": "string", - "enum": [ - "inherit", - "off", - "enabled" - ] + "enum": [ + "inherit", + "off", + "enabled" + ] }, "moderationRules": { "type": "array", @@ -9033,180 +9220,6 @@ "moderationBannedLabel": { "type": "string" }, - "typeLabels": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "linkedIssueLabelPropagation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "mode": { - "type": "string", - "enum": [ - "exclusive_type_label" - ] - }, - "mappings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issueLabel": { - "type": "string" - }, - "prLabel": { - "type": "string" - }, - "removeOtherTypeLabels": { - "type": "boolean" - } - }, - "required": [ - "issueLabel", - "prLabel", - "removeOtherTypeLabels" - ] - } - } - }, - "required": [ - "enabled", - "mode", - "mappings" - ] - }, - "claCheckRunAppSlug": { - "type": "string", - "nullable": true - }, - "expectedCiContexts": { - "type": "array", - "items": { - "type": "string" - } - }, - "reviewCheckMode": { - "type": "string", - "enum": [ - "required", - "visible", - "disabled" - ] - }, - "hardGuardrailGlobs": { - "type": "array", - "nullable": true, - "items": { - "type": "string" - } - }, - "manualReviewLabel": { - "type": "string", - "nullable": true - }, - "readyToMergeLabel": { - "type": "string", - "nullable": true - }, - "changesRequestedLabel": { - "type": "string", - "nullable": true - }, - "migrationCollisionLabel": { - "type": "string", - "nullable": true - }, - "pendingClosureLabel": { - "type": "string", - "nullable": true - }, - "linkedIssueHardRules": { - "type": "object", - "properties": { - "ownerAssignedClose": { - "type": "string", - "enum": [ - "block", - "off" - ] - }, - "assignedIssueClose": { - "type": "string", - "enum": [ - "block", - "off" - ] - }, - "missingPointLabelClose": { - "type": "string", - "enum": [ - "block", - "off" - ] - }, - "maintainerOnlyLabelClose": { - "type": "string", - "enum": [ - "block", - "off" - ] - }, - "pointBearingLabels": { - "type": "array", - "items": { - "type": "string" - } - }, - "maintainerOnlyLabels": { - "type": "array", - "items": { - "type": "string" - } - }, - "defaultLabelRepo": { - "type": "boolean" - }, - "verifyBeforeClose": { - "type": "boolean" - }, - "closeDelaySeconds": { - "type": "integer", - "minimum": 0, - "maximum": 300 - } - }, - "required": [ - "ownerAssignedClose", - "assignedIssueClose", - "missingPointLabelClose", - "maintainerOnlyLabelClose", - "pointBearingLabels", - "maintainerOnlyLabels", - "defaultLabelRepo", - "verifyBeforeClose", - "closeDelaySeconds" - ] - }, - "autoProjectMilestoneMatch": { - "type": "string", - "enum": [ - "off", - "suggest", - "auto" - ] - }, - "autoProjectMilestoneMatchBackend": { - "type": "string", - "enum": [ - "github", - "linear" - ] - }, "reviewEvasionProtection": { "type": "string", "enum": [ @@ -9220,6 +9233,14 @@ }, "reviewEvasionComment": { "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true } }, "required": [ @@ -9319,9 +9340,6 @@ "enabled" ] }, - "autoLabelEnabled": { - "type": "boolean" - }, "reviewCheckMode": { "type": "string", "enum": [ @@ -9344,6 +9362,9 @@ "github", "linear" ] + }, + "autoLabelEnabled": { + "type": "boolean" } }, "required": [ @@ -9585,6 +9606,13 @@ "type": "string", "nullable": true }, + "authMode": { + "type": "string", + "enum": [ + "local", + "broker" + ] + }, "requiredPermissions": { "type": "object", "additionalProperties": { @@ -9660,13 +9688,6 @@ "items": { "type": "string" } - }, - "authMode": { - "type": "string", - "enum": [ - "local", - "broker" - ] } }, "required": [ @@ -9748,6 +9769,29 @@ "enabled" ] }, + "reviewCheckMode": { + "type": "string", + "enum": [ + "required", + "visible", + "disabled" + ] + }, + "autoProjectMilestoneMatch": { + "type": "string", + "enum": [ + "off", + "suggest", + "auto" + ] + }, + "autoProjectMilestoneMatchBackend": { + "type": "string", + "enum": [ + "github", + "linear" + ] + }, "gatePack": { "type": "string", "enum": [ @@ -9825,6 +9869,9 @@ "autoLabelEnabled": { "type": "boolean" }, + "typeLabelsEnabled": { + "type": "boolean" + }, "gittensorLabel": { "type": "string" }, @@ -9912,32 +9959,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "typeLabelsEnabled": { - "type": "boolean" - }, - "reviewCheckMode": { - "type": "string", - "enum": [ - "required", - "visible", - "disabled" - ] - }, - "autoProjectMilestoneMatch": { - "type": "string", - "enum": [ - "off", - "suggest", - "auto" - ] - }, - "autoProjectMilestoneMatchBackend": { - "type": "string", - "enum": [ - "github", - "linear" - ] } }, "required": [ @@ -15549,8 +15570,176 @@ } } }, - "401": { - "description": "Unauthorized" + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/app/analytics/daily-rollups": { + "get": { + "responses": { + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/app/analytics/mcp-compatibility": { + "get": { + "responses": { + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/app/selfhost/queue/dead/{id}/replay": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "example": "812" + }, + "required": true, + "description": "Dead-letter job id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Job replayed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid job id" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Dead-letter job not found" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/app/selfhost/queue/dead/{id}": { + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "example": "812" + }, + "required": true, + "description": "Dead-letter job id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Job deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid job id" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Dead-letter job not found" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" } }, "security": [ @@ -15563,11 +15752,11 @@ ] } }, - "/v1/app/analytics/daily-rollups": { - "get": { + "/v1/app/selfhost/queue/dead": { + "delete": { "responses": { "200": { - "description": "Live app API response", + "description": "Dead-letter jobs purged", "content": { "application/json": { "schema": { @@ -15581,6 +15770,12 @@ }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" } }, "security": [ @@ -15591,13 +15786,33 @@ "GittensorySessionCookie": [] } ] - } - }, - "/v1/app/analytics/mcp-compatibility": { + }, "get": { + "parameters": [ + { + "schema": { + "type": "string", + "example": "25" + }, + "required": false, + "description": "Maximum rows to return, clamped from 1 to 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "0" + }, + "required": false, + "description": "Pagination offset, floored to 0.", + "name": "offset", + "in": "query" + } + ], "responses": { "200": { - "description": "Live app API response", + "description": "Paginated dead-letter jobs for the self-host queue backend", "content": { "application/json": { "schema": { @@ -15609,8 +15824,17 @@ } } }, + "400": { + "description": "Invalid query" + }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin (e.g. Cloudflare)" } }, "security": [ @@ -16270,209 +16494,6 @@ } ] } - }, - "/v1/app/selfhost/queue/dead": { - "get": { - "responses": { - "200": { - "description": "Paginated dead-letter jobs for the self-host queue backend", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid query" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin (e.g. Cloudflare)" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "example": "25" - }, - "required": false, - "description": "Maximum rows to return, clamped from 1 to 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "example": "0" - }, - "required": false, - "description": "Pagination offset, floored to 0.", - "name": "offset", - "in": "query" - } - ] - }, - "delete": { - "responses": { - "200": { - "description": "Dead-letter jobs purged", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/app/selfhost/queue/dead/{id}/replay": { - "post": { - "responses": { - "200": { - "description": "Job replayed", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid job id" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, - "404": { - "description": "Dead-letter job not found" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "example": "812" - }, - "required": true, - "description": "Dead-letter job id.", - "name": "id", - "in": "path" - } - ] - } - }, - "/v1/app/selfhost/queue/dead/{id}": { - "delete": { - "responses": { - "200": { - "description": "Job deleted", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid job id" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, - "404": { - "description": "Dead-letter job not found" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "example": "812" - }, - "required": true, - "description": "Dead-letter job id.", - "name": "id", - "in": "path" - } - ] - } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 58fa0b1474..e0ceccdc38 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -653,6 +653,12 @@ export const RepositorySettingsSchema = z closeDelaySeconds: z.number().int().min(0).max(300), }) .optional(), + unlinkedIssueGuardrail: z + .object({ + mode: z.enum(["hold", "off"]), + minConfidence: z.number().min(0).max(1), + }) + .optional(), gittensorLabel: z.string(), blacklistLabel: z.string().nullable(), createMissingLabel: z.boolean(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index b51299cf6c..112f9659ab 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -464,7 +464,10 @@ import { import { loadLinkedIssueHardRules, resolveLinkedIssueHardRule, + resolveLinkedIssueHasOpenReference, } from "../review/linked-issue-hard-rules"; +import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config"; +import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail"; import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire"; import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire"; import { @@ -1646,20 +1649,22 @@ async function sweepRepoRegate( const others = openPullRequests.filter( (other) => other.number !== pr.number, ); - // Thread linked-issue authors so the re-gate sweep applies the self-authored-linked-issue block too — without - // this a self-authored PR re-gated by the sweep escapes a block the main webhook path applies. (#self-authored-parity) - const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins( + // Thread linked-issue authors + the open-reference check so the re-gate sweep applies the same + // self-authored-linked-issue block AND stale-issue-link countermeasure the main webhook path applies — + // without this a self-authored or stale-link-gaming PR re-gated by the sweep escapes both. (#self-authored-parity, #unlinked-issue-guardrail-followup) + const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext( env, sweepInstallationId, repoFullName, pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: others, requireLinkedIssue, duplicateWinnerEnabled, linkedIssueAuthorLogins, + confirmedNoOpenLinkedIssue, }); const gate = evaluateGateCheck( advisory, @@ -2483,6 +2488,32 @@ async function runAgentMaintenancePlanAndExecute( installationId, }); + // Unlinked-issue guardrail (#unlinked-issue-guardrail, credibility-gate-farming defense): when this PR + // links NO issue and the repo opted in (settings.unlinkedIssueGuardrail.mode === "hold"), check whether the + // diff appears to directly, unambiguously solve an EXISTING open issue that was never linked -- a possible + // sign of a contributor slicing an issue into unlinked PRs to dodge scope scrutiny while still farming + // merge-ratio credibility. Config-gated AND linked-issue-count-gated at the CALL SITE (not just inside the + // resolver) so the diff-building work below is skipped entirely for the default-off / already-linked cases + // -- byte-identical extra cost, mirroring migrationCollisionHold's own gating above. A FIRST confirmed match + // only ever HOLDS the PR for manual review (folded into heldForManualReview); a CONFIRMED REPEAT by the + // same contributor (#unlinked-issue-guardrail-followup, tracked via audit_events) escalates to a CLOSE. + const unlinkedIssueGuardrailConfig = settings.unlinkedIssueGuardrail ?? DEFAULT_UNLINKED_ISSUE_GUARDRAIL; + const unlinkedIssueMatchDisposition = + unlinkedIssueGuardrailConfig.mode === "hold" && pr.linkedIssues.length === 0 + ? await resolveUnlinkedIssueMatchDisposition(env, { + repoFullName, + config: unlinkedIssueGuardrailConfig, + linkedIssueCount: pr.linkedIssues.length, + prTitle: pr.title, + prBody: pr.body, + changedPaths, + diff: buildAiReviewDiff(changedFiles), + prAuthorLogin: pr.authorLogin, + }) + : undefined; + const unlinkedIssueMatchHold = unlinkedIssueMatchDisposition?.kind === "hold" ? unlinkedIssueMatchDisposition : undefined; + const unlinkedIssueMatchClose = unlinkedIssueMatchDisposition?.kind === "close" ? unlinkedIssueMatchDisposition : undefined; + // Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global // list unions in once its table lands). A match short-circuits the planner to a deterministic label + close // ahead of merit/CI/AI; only the configured label (default "slop") reaches public actions. @@ -2655,6 +2686,8 @@ async function runAgentMaintenancePlanAndExecute( closeDelaySeconds: linkedIssueRulesConfig.closeDelaySeconds, }, ...(migrationCollisionHold !== undefined ? { migrationCollisionHold } : {}), + ...(unlinkedIssueMatchHold !== undefined ? { unlinkedIssueMatchHold } : {}), + ...(unlinkedIssueMatchClose !== undefined ? { unlinkedIssueMatchClose } : {}), pr: { mergeableState: liveMergeState ?? pr.mergeableState, reviewDecision: liveReviewDecision ?? pr.reviewDecision, @@ -2988,16 +3021,10 @@ async function reReviewStoredPullRequest( )) ) return; - const [cachedOtherOpenPullRequests, linkedIssueAuthorLogins] = + const [cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] = await Promise.all([ listOtherOpenPullRequests(env, repoFullName, prNumber), - resolveLinkedIssueAuthorLogins( - env, - installationId, - repoFullName, - pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", - ), + resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings), ]); // #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the advisory // (and the disposition below) elect the cluster winner, so the real lowest-OPEN PR is never demoted+auto-closed. @@ -3012,6 +3039,7 @@ async function reReviewStoredPullRequest( otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); await persistAdvisory(env, advisory); @@ -5384,19 +5412,14 @@ async function processGitHubWebhook( }); return; } - // Resolve settings first so the self-authored live-fetch fallback only fires when its gate is in block mode. + // Resolve settings first so the self-authored + open-reference live-fetch fallbacks only fire when their + // respective gates are in block mode. const settings = await resolveRepositorySettings(env, repoFullName); - const [repo, cachedOtherOpenPullRequests, linkedIssueAuthorLogins] = + const [repo, cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] = await Promise.all([ getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), - resolveLinkedIssueAuthorLogins( - env, - installationId, - repoFullName, - pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", - ), + resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings), ]); // #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the // advisory (and the disposition) elect the cluster winner, so the real lowest-OPEN PR is never auto-closed. @@ -5411,6 +5434,7 @@ async function processGitHubWebhook( otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); await persistAdvisory(env, advisory); @@ -5961,6 +5985,27 @@ export async function resolveLinkedIssueAuthorLogins( ); } +// Shared per-call-site resolver for buildPullRequestAdvisory's linked-issue-derived context +// (#unlinked-issue-guardrail-followup). Every gate-evaluating call site (the main webhook path, the cron +// sweep, the heavy re-review pass, and authorized PR actions) already threads `linkedIssueAuthorLogins` the +// same way; bundling the new open-reference check into the SAME resolver keeps all of them in parity rather +// than risking only some remembering to add it. The live open-reference fetch is skipped entirely (resolves +// `true` with no network call) unless `linkedIssueGateMode` is actually "block" -- the only mode where +// whether a citation is open can change the gate's outcome. +export async function resolveLinkedIssueAdvisoryContext( + env: Env, + installationId: number | null | undefined, + repoFullName: string, + linkedIssues: number[], + settings: Pick, +): Promise<{ linkedIssueAuthorLogins: (string | null)[]; confirmedNoOpenLinkedIssue: boolean }> { + const [linkedIssueAuthorLogins, hasOpenReference] = await Promise.all([ + resolveLinkedIssueAuthorLogins(env, installationId, repoFullName, linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"), + settings.linkedIssueGateMode === "block" ? resolveLinkedIssueHasOpenReference({ env, repoFullName, linkedIssues, installationId }) : Promise.resolve(true), + ]); + return { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue: !hasOpenReference }; +} + export function shouldCollectSlopEvidence( settings: Pick, ): boolean { @@ -10168,19 +10213,21 @@ export async function buildAuthorizedPrActionAdvisory( getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), ]); - // Mirror the main webhook path: thread linked-issue authors so an authorized PR action (gate-override / panel - // retrigger) honors the self-authored-linked-issue block too. installationId comes from the repo record. (#self-authored-parity) - const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins( + // 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- + // issue-link countermeasure. installationId comes from the repo record. (#self-authored-parity, #unlinked-issue-guardrail-followup) + const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext( env, repo?.installationId ?? null, repoFullName, pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); return { repo, advisory }; diff --git a/src/review/linked-issue-hard-rules.ts b/src/review/linked-issue-hard-rules.ts index ee63a8c08e..5750f859e8 100644 --- a/src/review/linked-issue-hard-rules.ts +++ b/src/review/linked-issue-hard-rules.ts @@ -1,5 +1,6 @@ -import { fetchLinkedIssueFacts } from "../github/backfill"; +import { fetchLinkedIssueFacts, type LinkedIssueFactsFetch } from "../github/backfill"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; +import { createInstallationToken } from "../github/app"; import { extractLinkedIssueNumbersWithOverflow } from "../db/repositories"; import { resolveRepositorySettings } from "../settings/repository-settings"; import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "./linked-issue-hard-rules-config"; @@ -184,3 +185,49 @@ export async function resolveLinkedIssueHardRule(args: { } return evaluateLinkedIssueHardRules({ issues: issueFacts, config: args.config, repoOwner: args.repoOwner, prAuthorLogin: args.prAuthorLogin }); } + +// ── Stale/fabricated-link countermeasure for the "must link an issue" HARD gate (#unlinked-issue-guardrail- +// followup) ────────────────────────────────────────────────────────────────────────────────────────────── +// +// `pr.linkedIssues` (extractLinkedIssueNumbersWithOverflow) is a pure body-text regex match — it never checks +// whether the cited issue is actually OPEN. So a repo running `linkedIssueGateMode: "block"` (requires a +// linked issue to merge) can be satisfied by a contributor citing an already-CLOSED or fabricated issue +// number, which defeats the whole point of requiring a link. This pair of functions gives the gate a +// verified, fail-open "is at least one citation a real, currently open issue" signal to use INSTEAD of bare +// presence, without changing what `pr.linkedIssues` itself means anywhere else it's used (duplicate-winner +// overlap, label propagation, scoring, etc. all keep reading raw presence). + +/** + * PURE evaluator. `true` means "treat the presence check as satisfied" — either a linked issue is CONFIRMED + * open, or at least one fetch was ambiguous (`fetch_error`) and we can't rule out a real open issue behind + * it. `false` — the only case this whole mechanism exists to catch — means EVERY fetched result conclusively + * resolved to NOT an open issue (found-but-closed, or a confirmed 404), with zero ambiguity. An empty input + * (nothing was fetched, e.g. the caller didn't need to check) fails open to `true` — the caller is + * responsible for handling "no linked issues at all" separately (that's the existing bare-presence check). + */ +export function hasVerifiableOpenLinkedIssueReference(fetchResults: LinkedIssueFactsFetch[]): boolean { + if (fetchResults.length === 0) return true; + if (fetchResults.some((result) => result.status === "found" && result.facts.state === "open")) return true; + return fetchResults.some((result) => result.status === "fetch_error"); +} + +/** + * Orchestrate the live per-issue fetch for {@link hasVerifiableOpenLinkedIssueReference}. Mints its own + * installation token (falling back to the public token, exactly like fetchLinkedIssueFacts's own + * hasProvenAccess discipline degrades a public-token 404 to `fetch_error` rather than a confirmed miss) so + * callers only need an `installationId`, mirroring `resolveLinkedIssueAuthorLogins`'s lazy-token pattern. + * Fail-safe: a token-mint failure still proceeds on the public token rather than skipping the check. + */ +export async function resolveLinkedIssueHasOpenReference(args: { + env: Env; + repoFullName: string; + linkedIssues: number[]; + installationId?: number | null | undefined; +}): Promise { + if (args.linkedIssues.length === 0) return true; + const ciToken = args.installationId ? await createInstallationToken(args.env, args.installationId).catch(() => undefined) : undefined; + const token = ciToken ?? args.env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubRateLimitAdmissionKeyForToken(args.env, token, args.installationId); + const fetchResults = await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey))); + return hasVerifiableOpenLinkedIssueReference(fetchResults); +} diff --git a/src/review/unlinked-issue-guardrail-config.ts b/src/review/unlinked-issue-guardrail-config.ts new file mode 100644 index 0000000000..8f0dff8130 --- /dev/null +++ b/src/review/unlinked-issue-guardrail-config.ts @@ -0,0 +1,47 @@ +import type { UnlinkedIssueGuardrailConfig, UnlinkedIssueGuardrailMode } from "../types"; + +const VALID_UNLINKED_ISSUE_GUARDRAIL_MODES: readonly UnlinkedIssueGuardrailMode[] = ["hold", "off"]; +const DEFAULT_MIN_CONFIDENCE = 0.85; + +export const DEFAULT_UNLINKED_ISSUE_GUARDRAIL: UnlinkedIssueGuardrailConfig = { + mode: "off", + minConfidence: DEFAULT_MIN_CONFIDENCE, +}; + +export function isUnlinkedIssueGuardrailMode(value: unknown): value is UnlinkedIssueGuardrailMode { + return typeof value === "string" && (VALID_UNLINKED_ISSUE_GUARDRAIL_MODES as readonly string[]).includes(value); +} + +function normalizeMode(value: unknown, warnings: string[]): UnlinkedIssueGuardrailMode { + if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode; + if (isUnlinkedIssueGuardrailMode(value)) return value; + warnings.push(`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "${DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode}".`); + return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode; +} + +function normalizeMinConfidence(value: unknown, warnings: string[]): number { + if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.minConfidence; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { + warnings.push(`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "${DEFAULT_MIN_CONFIDENCE}".`); + return DEFAULT_MIN_CONFIDENCE; + } + return value; +} + +/** + * Normalize a raw `.gittensory.yml settings.unlinkedIssueGuardrail` value into a typed config, + * fail-safe: any malformed field falls back to its own default and pushes a warning rather than + * rejecting the whole block. Mirrors `normalizeLinkedIssueHardRulesConfig`'s per-field discipline. + */ +export function normalizeUnlinkedIssueGuardrailConfig(input: unknown, warnings: string[]): UnlinkedIssueGuardrailConfig { + if (input === undefined) return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.unlinkedIssueGuardrail must be an object; using the default off policy."); + return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL }; + } + const record = input as Record; + return { + mode: normalizeMode(record.mode, warnings), + minConfidence: normalizeMinConfidence(record.minConfidence, warnings), + }; +} diff --git a/src/review/unlinked-issue-guardrail.ts b/src/review/unlinked-issue-guardrail.ts new file mode 100644 index 0000000000..56688c7851 --- /dev/null +++ b/src/review/unlinked-issue-guardrail.ts @@ -0,0 +1,120 @@ +// Orchestrator for the unlinked-issue guardrail (#unlinked-issue-guardrail, credibility-gate-farming +// defense). Combines the config gate, the cheap deterministic pre-filter (src/signals/unlinked-issue- +// candidates.ts), and the AI precision check (./unlinked-issue-match.ts) into a single per-PR decision: does +// this PR's diff appear to directly solve an EXISTING open issue it never linked? A FIRST confirmed match +// HOLDS the PR for manual review (never auto-closes, never auto-merges past it) -- see src/settings/ +// agent-actions.ts's `unlinkedIssueMatchHold`. A CONFIRMED REPEAT by the SAME contributor (tracked via the +// existing `audit_events` ledger -- the same general-purpose actor/event-type ledger already used for the +// review-nag cooldown and decision-pack debounce, `hasRecentAuditEvent`/`recordAuditEvent` in +// db/repositories.ts) escalates to an actual CLOSE (`unlinkedIssueMatchClose`), since a second occurrence is +// no longer a coincidence worth a human's benefit of the doubt. +// +// Cost-bounded by construction: every short-circuit below runs BEFORE the DB read or any AI call, so a +// repo that hasn't opted in (the default) or a PR that already links an issue (the common case) pays +// nothing beyond two boolean checks. + +import { hasRecentAuditEvent, listOpenIssues, recordAuditEvent } from "../db/repositories"; +import { findUnlinkedIssueCandidates, type CandidateOpenIssue } from "../signals/unlinked-issue-candidates"; +import type { UnlinkedIssueGuardrailConfig } from "../types"; +import { verifyUnlinkedIssueMatch } from "./unlinked-issue-match"; + +/** Shared with any future reader that wants to correlate these holds/closes across repos for one contributor. */ +export const UNLINKED_ISSUE_MATCH_AUDIT_EVENT_TYPE = "github_app.unlinked_issue_match_hold"; +// Same recency convention as submitter-reputation.ts's REPUTATION_WINDOW_DAYS -- a match from a year ago +// shouldn't silently escalate every fresh, unrelated match into an auto-close forever. +const UNLINKED_ISSUE_MATCH_REPEAT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; + +export type UnlinkedIssueMatchDisposition = { kind: "hold"; reason: string; comment: string } | { kind: "close"; reason: string; comment: string }; + +export type ResolveUnlinkedIssueMatchDispositionInput = { + repoFullName: string; + config: UnlinkedIssueGuardrailConfig; + /** The PR's OWN linked-issue count (already extracted by the caller) -- the guardrail only ever runs + * against a PR that links NOTHING; a PR linking a different issue is out of scope for this check. */ + linkedIssueCount: number; + prTitle: string; + prBody: string | null | undefined; + changedPaths: string[]; + diff: string; + /** Needed to detect a repeat by this SAME contributor. A missing/unknown author can never be reliably + * correlated across PRs, so repeat-detection is skipped entirely and a confirmed match always holds + * (fail-safe: never escalate to a close on an unidentifiable author). */ + prAuthorLogin: string | null | undefined; +}; + +/** Has this contributor triggered a confirmed unlinked-issue match anywhere (any repo) within the recency + * window? Fail-safe: a read error resolves to "no prior match" (never wrongly escalates on a DB hiccup). */ +async function hasPriorUnlinkedIssueMatch(env: Env, authorLogin: string): Promise { + const sinceIso = new Date(Date.now() - UNLINKED_ISSUE_MATCH_REPEAT_WINDOW_MS).toISOString(); + return hasRecentAuditEvent(env, authorLogin, UNLINKED_ISSUE_MATCH_AUDIT_EVENT_TYPE, sinceIso).catch(() => false); +} + +/** Record THIS occurrence so a later PR from the same contributor can be recognized as a repeat. Fire-and- + * forget: a write failure must never block the gate -- worst case, a future occurrence fails open to a hold + * instead of escalating, never the reverse. */ +async function recordUnlinkedIssueMatchOccurrence(env: Env, repoFullName: string, authorLogin: string, issueNumber: number): Promise { + await recordAuditEvent(env, { + eventType: UNLINKED_ISSUE_MATCH_AUDIT_EVENT_TYPE, + actor: authorLogin, + targetKey: `${repoFullName}#${issueNumber}`, + outcome: "completed", + detail: `unlinked PR diff matched open issue #${issueNumber} without a linking reference`, + }).catch(() => undefined); +} + +/** + * Resolve the unlinked-issue-match disposition for one PR, or `undefined` when nothing should hold or close + * it. Checks candidates in the pre-filter's ranked order and acts on the FIRST one that clears + * `config.minConfidence`, so at most one issue is ever cited even if several loosely qualify. + */ +export async function resolveUnlinkedIssueMatchDisposition(env: Env, input: ResolveUnlinkedIssueMatchDispositionInput): Promise { + if (input.config.mode !== "hold") return undefined; + if (input.linkedIssueCount > 0) return undefined; + const openIssues = await listOpenIssues(env, input.repoFullName); + const candidateIssues: CandidateOpenIssue[] = openIssues.map((issue) => ({ + number: issue.number, + title: issue.title, + body: issue.body ?? null, + labels: issue.labels, + })); + const candidates = findUnlinkedIssueCandidates({ + prTitle: input.prTitle, + prBody: input.prBody, + changedPaths: input.changedPaths, + openIssues: candidateIssues, + }); + if (candidates.length === 0) return undefined; + const authorLogin = input.prAuthorLogin?.trim() || null; + for (const candidate of candidates) { + const verdict = await verifyUnlinkedIssueMatch(env, { + prTitle: input.prTitle, + prBody: input.prBody, + diff: input.diff, + candidate: candidate.issue, + }); + if (!verdict.matched || verdict.confidence < input.config.minConfidence) continue; + const evidenceSuffix = verdict.evidence ? ` (${verdict.evidence})` : ""; + if (!authorLogin) { + return { + kind: "hold", + reason: `this PR links no issue, but appears to directly solve open issue #${candidate.issue.number} without linking it${evidenceSuffix}`, + comment: `This PR doesn't link an issue, but its diff appears to directly solve #${candidate.issue.number}. If that's right, please add a linking reference (e.g. \`Closes #${candidate.issue.number}\`) so it's credited correctly; if this is a coincidence, a maintainer will clear this hold shortly.`, + }; + } + const isRepeat = await hasPriorUnlinkedIssueMatch(env, authorLogin); + await recordUnlinkedIssueMatchOccurrence(env, input.repoFullName, authorLogin, candidate.issue.number); + if (isRepeat) { + return { + kind: "close", + reason: `this PR appears to directly solve open issue #${candidate.issue.number} without linking it${evidenceSuffix} — a repeat of the same unlinked-issue pattern already flagged on an earlier PR from this contributor`, + comment: `Closing: this PR doesn't link an issue, but its diff appears to directly solve #${candidate.issue.number} — the same unlinked-issue pattern already flagged on one of your earlier PRs. Please link the issue you're solving (e.g. \`Closes #N\`) going forward.`, + }; + } + return { + kind: "hold", + reason: `this PR links no issue, but appears to directly solve open issue #${candidate.issue.number} without linking it${evidenceSuffix}`, + comment: `This PR doesn't link an issue, but its diff appears to directly solve #${candidate.issue.number}. If that's right, please add a linking reference (e.g. \`Closes #${candidate.issue.number}\`) so it's credited correctly; if this is a coincidence, a maintainer will clear this hold shortly.`, + }; + } + return undefined; +} diff --git a/src/review/unlinked-issue-match.ts b/src/review/unlinked-issue-match.ts new file mode 100644 index 0000000000..fd8a640536 --- /dev/null +++ b/src/review/unlinked-issue-match.ts @@ -0,0 +1,99 @@ +// AI verification for the unlinked-issue guardrail (#unlinked-issue-guardrail). Given ONE candidate open +// issue already surfaced by the cheap deterministic pre-filter (src/signals/unlinked-issue-candidates.ts), +// ask the AI reviewer whether a PR's diff DIRECTLY and UNAMBIGUOUSLY solves that specific issue. This is +// the actual precision gate for the whole guardrail, so it fails closed at every step: a missing binding, a +// thrown provider error, an unparseable response, or a matched:true with no usable confidence all resolve to +// "not matched" rather than risk a false positive holding a legitimate PR. +// +// CRITICAL: this can HOLD a PR (suppress an otherwise-ready merge), so it uses ONLY the free/self-host AI +// path (`env.AI.run`) -- never BYOK -- mirroring ai-review.ts's own block-mode rule that BYOK must never +// affect who gets blocked. There is no `providerKey` parameter here on purpose. + +import { BEST_REVIEW_MODELS, coerceAiText, extractLastJsonObject, RELIABLE_FALLBACK_MODELS } from "../services/ai-review"; +import type { CandidateOpenIssue } from "../signals/unlinked-issue-candidates"; + +export type UnlinkedIssueMatchVerdict = { + matched: boolean; + confidence: number; + evidence: string; +}; + +const NO_MATCH: UnlinkedIssueMatchVerdict = { matched: false, confidence: 0, evidence: "" }; + +const MAX_TOKENS = 400; +// This check only needs enough diff to judge scope overlap, not the full multi-file review budget. +const DIFF_CHAR_BUDGET = 6_000; + +type AiRunner = { run: (model: string, options: unknown, extra?: unknown) => Promise }; + +function buildSystemPrompt(): string { + return ( + "You are verifying whether a pull request's diff DIRECTLY and UNAMBIGUOUSLY solves a SPECIFIC GitHub " + + "issue that the PR did not link. Be conservative: default to matched=false unless the diff obviously " + + "and substantially addresses exactly what the issue describes. A shared file, a vaguely related topic, " + + "or a partial fix is NOT a match. Respond with ONLY a JSON object: " + + '{"matched": boolean, "confidence": number between 0 and 1, "evidence": "one sentence citing the specific overlap, or why it does not match"}.' + ); +} + +function buildUserPrompt(input: { prTitle: string; prBody: string | null | undefined; diff: string; candidate: CandidateOpenIssue }): string { + const diff = input.diff.length > DIFF_CHAR_BUDGET ? `${input.diff.slice(0, DIFF_CHAR_BUDGET)}\n… (diff truncated)` : input.diff; + return [ + `PULL REQUEST TITLE: ${input.prTitle}`, + `PULL REQUEST BODY: ${input.prBody?.trim() || "(empty)"}`, + `PULL REQUEST DIFF:\n${diff}`, + `CANDIDATE ISSUE #${input.candidate.number}: ${input.candidate.title}`, + `ISSUE BODY: ${input.candidate.body?.trim() || "(empty)"}`, + ].join("\n\n"); +} + +function parseVerdict(text: string): UnlinkedIssueMatchVerdict { + const jsonText = extractLastJsonObject(text); + if (!jsonText) return NO_MATCH; + let parsed: Record; + try { + parsed = JSON.parse(jsonText) as Record; + } catch { + return NO_MATCH; + } + const confidence = typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) ? Math.min(1, Math.max(0, parsed.confidence)) : 0; + const evidence = typeof parsed.evidence === "string" ? parsed.evidence : ""; + // A matched:true verdict with no usable (>0) confidence is untrustworthy -- fail closed rather than trust + // an unscored "yes" (this is the one place a model's own claim of a match can still be overridden). + const matched = parsed.matched === true && confidence > 0; + return { matched, confidence, evidence }; +} + +/** + * Ask the FREE/self-host AI provider whether a PR's diff directly solves ONE candidate open issue. Tries + * the primary review model, then the reliable fallback, on a thrown error; returns {@link NO_MATCH} if + * both fail, the binding is absent, or the response can't be parsed into a usable verdict. Never throws. + */ +export async function verifyUnlinkedIssueMatch( + env: Env, + input: { prTitle: string; prBody: string | null | undefined; diff: string; candidate: CandidateOpenIssue }, +): Promise { + const ai = env.AI as unknown as AiRunner | undefined; + if (!ai || typeof ai.run !== "function") return NO_MATCH; + const system = buildSystemPrompt(); + const user = buildUserPrompt(input); + const models = [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]]; + for (const model of models) { + try { + const result = await ai.run(model, { + max_tokens: MAX_TOKENS, + temperature: 0, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + }); + return parseVerdict(coerceAiText(result)); + } catch { + // try the next model + } + } + return NO_MATCH; +} + +export const __unlinkedIssueMatchInternals = { buildSystemPrompt, buildUserPrompt, parseVerdict }; diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 5511105051..0ec00332ac 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -190,6 +190,15 @@ export function buildPullRequestAdvisory( * surface a `self_authored_linked_issue` finding when the PR author also opened the linked issue. Absent * or empty ⇒ the finding is never raised (fail-open: unknown issue authorship stays advisory-only). */ linkedIssueAuthorLogins?: (string | null | undefined)[]; + /** Same-account issue-avoidance countermeasure (#unlinked-issue-guardrail-followup): `pr.linkedIssues` is + * populated by a pure body-text regex that never checks whether the cited issue is actually OPEN, so a + * contributor can satisfy `linkedIssueGateMode: "block"` by citing an already-CLOSED (or fabricated) + * issue number. When the caller has live-verified that NONE of this PR's linked issue numbers resolve to + * a confirmed-open issue, it sets this true and `missing_linked_issue` fires exactly as if nothing were + * linked at all. Absent/false ⇒ byte-identical to today (presence alone still satisfies the requirement) + * — this is fail-open by construction: the caller only ever sets it true after a live check confirms + * every reference is dead, never on ambiguity. */ + confirmedNoOpenLinkedIssue?: boolean; } = {}, ): Advisory { const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; @@ -215,7 +224,7 @@ export function buildPullRequestAdvisory( action: "Re-deliver the webhook or wait for the next sync.", }); } else { - addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? []); + addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? [], Boolean(context.confirmedNoOpenLinkedIssue)); } return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); } @@ -675,6 +684,7 @@ function addPullRequestFindings( requireLinkedIssue: boolean, duplicateWinnerEnabled: boolean, linkedIssueAuthorLogins: (string | null | undefined)[], + confirmedNoOpenLinkedIssue: boolean, ): void { if (pr.state !== "open") { findings.push({ @@ -684,12 +694,15 @@ function addPullRequestFindings( detail: `The pull request state is ${pr.state}.`, }); } - if (pr.linkedIssues.length === 0 && requireLinkedIssue) { + const noLinkedIssueCited = pr.linkedIssues.length === 0; + if ((noLinkedIssueCited || confirmedNoOpenLinkedIssue) && requireLinkedIssue) { findings.push({ code: "missing_linked_issue", severity: "warning", title: "No linked issue detected", - detail: "No closing reference or linked issue number was found in the PR metadata/body.", + detail: noLinkedIssueCited + ? "No closing reference or linked issue number was found in the PR metadata/body." + : "The PR cites an issue number, but it could not be verified as a currently open issue.", action: "If this PR is intended to solve an issue, link it explicitly in the PR body.", }); } else { diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index fd1b41f28d..4b01d8e74a 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -307,6 +307,21 @@ export type AgentActionPlanInput = { // emitted migration-collision label so the contributor knows why. Never causes a CLOSE — only // ever downgrades a would-merge into a held-for-review state, same risk profile as the guardrail hold. migrationCollisionHold?: { reason: string; comment: string } | undefined; + // Unlinked-issue guardrail (#unlinked-issue-guardrail, credibility-gate-farming defense). The trigger + // (runAgentMaintenancePlanAndExecute) has already run the deterministic pre-filter + AI verification for a + // PR that links NO issue -- this input is already the resolved "yes, hold this merge" verdict (or absent, + // meaning no confirmed direct match was found). Same risk profile as migrationCollisionHold: SUPPRESSES + // the merge (folded into `heldForManualReview`), never causes a CLOSE, and is the rare exception to "a + // missing linked issue is never a close reason" -- it only ever downgrades a would-merge into a held-for- + // review state so a human can confirm the match before it's credited. + unlinkedIssueMatchHold?: { reason: string; comment: string } | undefined; + // Same guardrail as unlinkedIssueMatchHold, but for a CONFIRMED REPEAT by the same contributor (tracked via + // audit_events, see resolveUnlinkedIssueMatchDisposition) -- a second occurrence is no longer a coincidence + // worth a human's benefit of the doubt, so this closes the PR one-shot instead of holding it. Deliberately + // NOT `closeConcreteEvidence` (stays subject to the close-precision breaker): the underlying signal is an + // AI semantic-match verdict, and a systematically-wrong match must not become breaker-proof just because + // it repeated. Mutually exclusive with unlinkedIssueMatchHold -- the resolver only ever returns one. + unlinkedIssueMatchClose?: { reason: string; comment: string } | undefined; pr: { mergeableState?: string | null | undefined; reviewDecision?: string | null | undefined; @@ -669,7 +684,19 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // is held separately, via the owner close-exemption below — never auto-closed.) Submission volume is NOT a // hold reason: a high-volume author's clean PR still merges and their bad PR still closes — the quality // gate, not a submission count, is the defense (anti-farming-by-manual-hold removed). - const heldForManualReview = guardrailHit || input.migrationCollisionHold !== undefined; + // + // A confirmed repeat unlinked-issue-match (unlinkedIssueMatchClose) is ALSO folded in here, but only when + // `close` autonomy is NOT acting (gate-review finding): without this, a repo running `merge: auto` with + // `close` unset/observe would see `unlinkedIssueMatchViolated` below evaluate false (it requires + // `acting("close")`), and — since nothing else accounts for the confirmed repeat — an otherwise-green PR + // would silently MERGE straight through the escalation instead of being held. When `close` IS acting, the + // dedicated close branch below handles it and this term is redundant (harmless: both paths agree the PR + // must not silently merge). + const heldForManualReview = + guardrailHit || + input.migrationCollisionHold !== undefined || + input.unlinkedIssueMatchHold !== undefined || + (input.unlinkedIssueMatchClose !== undefined && !acting("close")); const labels = resolveAgentDispositionLabels(input); // Canonical (reviewbot non-content-gate) policy, tuned to the operator's minimize-manual goal: merge-or-close // with high accuracy; manual review is the RARE exception. A PR is "review-good" when the gate passes AND CI is @@ -695,6 +722,12 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // blockers/conflicts/red CI; they hold only otherwise-ready PRs for manual review. // (Rebase-if-behind already ran above, so a red CI here is on the latest base — not a stale-base artifact.) (#ci-fail-closes-guarded) const willClose = closeEligible && acting("close") && (ciFailed || conclusion === "failure" || isConflict); + // Unlinked-issue-match REPEAT close (#unlinked-issue-guardrail-followup): a CONFIRMED repeat of the + // credibility-gate-farming pattern (tracked via audit_events in resolveUnlinkedIssueMatchDisposition) — not + // an immediate close on the first occurrence (that stays a hold, unlinkedIssueMatchHold), only once the same + // contributor has done it before. Takes PRECEDENCE over merge below, same reasoning as the linked-issue + // hard-rule close: a confirmed repeat offender must never auto-merge just because CI happens to be green. + const unlinkedIssueMatchViolated = input.unlinkedIssueMatchClose !== undefined && closeEligible && acting("close"); // Linked-issue HARD-RULE close (#linked-issue-hard-rules). A DETERMINISTIC verdict about the LINKED ISSUE // (owner-assigned / missing point-label / maintainer-only) — NOT an AI verdict, so there is no hallucination // to guard against: this close fires REGARDLESS of `guardrailHit`. It still only ever closes a CONTRIBUTOR @@ -770,6 +803,38 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne }); } + // 1d) unlinked-issue-match manual-review fallback (#unlinked-issue-guardrail) — mirrors 1c exactly, for the + // credibility-gate-farming guardrail: when review_state_label is OFF, surface the hold + evidence via the + // generic manualReviewLabel fallback so a confirmed match isn't silently invisible. Never duplicates 1's or + // 1c's label (hasLabelOrPlanned guard) if either already added it first. + if (reviewGood && input.unlinkedIssueMatchHold !== undefined && !acting("review_state_label") && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) { + actions.push({ + actionClass: "label", + autonomyClass: "merge", + requiresApproval: false, + reason: `verdict=${conclusion}; ${input.unlinkedIssueMatchHold.reason}`, + label: labels.manualReview, + labelOp: "add", + comment: sanitizePublicComment(input.unlinkedIssueMatchHold.comment), + }); + } + + // 1e) unlinked-issue-match REPEAT manual-review fallback when `close` autonomy can't act (gate-review + // finding): mirrors 1d, but for the escalated-repeat case folded into `heldForManualReview` above only + // when close isn't acting — without this, a confirmed repeat would silently MERGE with no visible signal + // at all (the dedicated close branch below never fires without `acting("close")`). + if (reviewGood && input.unlinkedIssueMatchClose !== undefined && !acting("close") && !acting("review_state_label") && labels.manualReview !== null && acting("merge") && !hasLabelOrPlanned(input.pr.labels, actions, labels.manualReview)) { + actions.push({ + actionClass: "label", + autonomyClass: "merge", + requiresApproval: false, + reason: `verdict=${conclusion}; ${input.unlinkedIssueMatchClose.reason}`, + label: labels.manualReview, + labelOp: "add", + comment: sanitizePublicComment(input.unlinkedIssueMatchClose.comment), + }); + } + // 2) review_state_label (#label-scoping) — ready-to-merge (review-good, unguarded) / manual-review // (review-good but guarded) / changes-requested (not review-good → will be closed for a contributor, held for // the owner). A pending linked-issue hard-rule close (flag OR close pass) forces the changes-requested label @@ -781,16 +846,29 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // A live migration-collision hold takes priority over a plain guardrail hold when both are true — it is // the more specific, actionable signal (tells the contributor exactly what to do: rebase), and gets its // own distinct label (#2550) so an operator can filter/alert on it separately from an ordinary guardrail. - const label = linkedIssueCloseInFlight || !reviewGood ? labels.changesRequested : input.migrationCollisionHold !== undefined ? labels.migrationCollision : heldForManualReview ? labels.manualReview : labels.readyToMerge; - const reason = linkedIssueCloseInFlight - ? `linked-issue hard rule: ${linkedIssueHardRule?.reason ?? "ineligible linked issue"}` - : !reviewGood - ? `verdict=${conclusion}${ciReason ? `; ${ciReason}` : ""}` + const label = + linkedIssueCloseInFlight || unlinkedIssueMatchViolated || !reviewGood + ? labels.changesRequested : input.migrationCollisionHold !== undefined - ? `verdict=${conclusion}; ${input.migrationCollisionHold.reason}` + ? labels.migrationCollision : heldForManualReview - ? `verdict=${conclusion}; ${guardrailReason}` - : `verdict=${conclusion}; CI green`; + ? labels.manualReview + : labels.readyToMerge; + const reason = linkedIssueCloseInFlight + ? `linked-issue hard rule: ${linkedIssueHardRule?.reason ?? "ineligible linked issue"}` + : unlinkedIssueMatchViolated + ? `verdict=${conclusion}; ${input.unlinkedIssueMatchClose!.reason}` + : !reviewGood + ? `verdict=${conclusion}${ciReason ? `; ${ciReason}` : ""}` + : input.migrationCollisionHold !== undefined + ? `verdict=${conclusion}; ${input.migrationCollisionHold.reason}` + : input.unlinkedIssueMatchHold !== undefined + ? `verdict=${conclusion}; ${input.unlinkedIssueMatchHold.reason}` + : input.unlinkedIssueMatchClose !== undefined + ? `verdict=${conclusion}; ${input.unlinkedIssueMatchClose.reason}` + : heldForManualReview + ? `verdict=${conclusion}; ${guardrailReason}` + : `verdict=${conclusion}; CI green`; if (label !== null && !hasLabelOrPlanned(input.pr.labels, actions, label)) { actions.push({ actionClass: "label", @@ -798,9 +876,18 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne requiresApproval: approval("review_state_label"), reason, label, - // Only the migration-collision hold carries a comment here — the guardrail/ready/changes labels never - // did and still don't (comment stays undefined, matching the pre-#2550 shape exactly). - ...(!linkedIssueCloseInFlight && reviewGood && input.migrationCollisionHold !== undefined ? { comment: sanitizePublicComment(input.migrationCollisionHold.comment) } : {}), + // Only the migration-collision hold and the unlinked-issue-match hold carry a comment here — the + // guardrail/ready/changes labels never did and still don't (comment stays undefined, matching the + // pre-#2550 shape exactly). Migration-collision takes priority when both are somehow true (matches + // the label-priority choice above). unlinkedIssueMatchViolated is excluded here too: its own CLOSE + // action already carries the full closeComment, so this label needs no separate comment. + ...(!linkedIssueCloseInFlight && !unlinkedIssueMatchViolated && reviewGood && input.migrationCollisionHold !== undefined + ? { comment: sanitizePublicComment(input.migrationCollisionHold.comment) } + : !linkedIssueCloseInFlight && !unlinkedIssueMatchViolated && reviewGood && input.unlinkedIssueMatchHold !== undefined + ? { comment: sanitizePublicComment(input.unlinkedIssueMatchHold.comment) } + : !linkedIssueCloseInFlight && !unlinkedIssueMatchViolated && reviewGood && input.unlinkedIssueMatchClose !== undefined + ? { comment: sanitizePublicComment(input.unlinkedIssueMatchClose.comment) } + : {}), }); } // Flag-then-close double-check, Pass 1: add the pending-closure label + a warning comment citing the specific @@ -915,6 +1002,25 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // Pin like merge/approve (#2452): lets the accept-time supersede check detect a force-push after staging. ...(input.pr.headSha ? { expectedHeadSha: input.pr.headSha } : {}), }); + } else if (unlinkedIssueMatchViolated) { + // A confirmed REPEAT of the same-account issue-avoidance pattern (#unlinked-issue-guardrail-followup) — + // close one-shot, same precedence-over-merge reasoning as the linked-issue hard-rule close above. Tagged + // "heuristic" (NOT closeConcreteEvidence): the underlying match is an AI verdict, so a systematically-wrong + // match must stay subject to the close-precision breaker even after it repeats. + const reason = input.unlinkedIssueMatchClose!.reason; + actions.push({ + actionClass: "close", + requiresApproval: approval("close"), + reason, + closeReasons: [reason], + closeComment: closeMessage([reason]), + closeKind: "heuristic", + // Never CI-driven (#2478 discipline: always explicit, never omitted, on every heuristic close) -- the + // executor's live-CI re-check would otherwise treat an omitted value as a legacy row requiring CI to + // still be red, wrongly denying this close on a green PR. + closeRequiresCiState: "not_required", + ...(input.pr.headSha ? { expectedHeadSha: input.pr.headSha } : {}), + }); } else if (canMerge) { actions.push({ actionClass: "merge", diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index e3e069454c..f759b31042 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,5 +1,5 @@ import { parse as parseYaml } from "yaml"; -import type { GatePolicyPack, GateRuleMode, JsonValue, LinkedIssueHardRulesConfig, LinkedIssueLabelPropagationConfig, PrTypeLabelSet, RepositorySettings, ReviewCheckMode } from "../types"; +import type { GatePolicyPack, GateRuleMode, JsonValue, LinkedIssueHardRulesConfig, LinkedIssueLabelPropagationConfig, PrTypeLabelSet, RepositorySettings, ReviewCheckMode, UnlinkedIssueGuardrailConfig } from "../types"; import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy"; import { normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; import { mergeContributorBlacklists, normalizeContributorBlacklist } from "../settings/contributor-blacklist"; @@ -7,6 +7,7 @@ import { normalizeAutoCloseExemptLogins } from "../settings/auto-close-exempt"; import { DEFAULT_TYPE_LABELS, normalizeTypeLabelSet } from "../settings/pr-type-label"; import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, normalizeLinkedIssueLabelPropagationConfig, VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES } from "../review/linked-issue-label-propagation"; import { DEFAULT_LINKED_ISSUE_HARD_RULES, isLinkedIssueHardRuleMode, normalizeLinkedIssueHardRulesConfig } from "../review/linked-issue-hard-rules-config"; +import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL, isUnlinkedIssueGuardrailMode, normalizeUnlinkedIssueGuardrailConfig } from "../review/unlinked-issue-guardrail-config"; import { normalizeModerationLabel, normalizeModerationRules } from "../settings/moderation-rules"; import { REES_ANALYZER_NAME_SET, type ReesAnalyzerName } from "../review/enrichment-analyzer-names"; import { hasUnsafeWildcardCount } from "./change-guardrail"; @@ -270,6 +271,7 @@ export type FocusManifestSettings = Partial< typeLabels?: Partial | null | undefined; linkedIssueLabelPropagation?: Partial | undefined; linkedIssueHardRules?: Partial | undefined; + unlinkedIssueGuardrail?: Partial | undefined; }; /** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. */ @@ -1281,6 +1283,21 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } else if (r.linkedIssueHardRules !== undefined) { warnings.push(`Manifest "settings.linkedIssueHardRules" must be an object; ignoring it and keeping any existing policy.`); } + // Unlinked-issue guardrail (#unlinked-issue-guardrail): same sparse-partial overlay contract as + // linkedIssueHardRules above -- a repo naming only `mode` must not silently reset `minConfidence` back to + // the built-in default. + if (typeof r.unlinkedIssueGuardrail === "object" && r.unlinkedIssueGuardrail !== null && !Array.isArray(r.unlinkedIssueGuardrail)) { + const rawGuardrail = r.unlinkedIssueGuardrail as Record; + const validated = normalizeUnlinkedIssueGuardrailConfig(rawGuardrail, warnings); + const sparseGuardrail: Partial = {}; + if (isUnlinkedIssueGuardrailMode(rawGuardrail.mode)) sparseGuardrail.mode = validated.mode; + if (typeof rawGuardrail.minConfidence === "number" && Number.isFinite(rawGuardrail.minConfidence) && rawGuardrail.minConfidence >= 0 && rawGuardrail.minConfidence <= 1) { + sparseGuardrail.minConfidence = validated.minConfidence; + } + out.unlinkedIssueGuardrail = sparseGuardrail; + } else if (r.unlinkedIssueGuardrail !== undefined) { + warnings.push(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`); + } // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in. @@ -2173,6 +2190,7 @@ export function resolveEffectiveSettings( typeLabels: typeLabelsOverride, linkedIssueLabelPropagation: linkedIssueLabelPropagationOverride, linkedIssueHardRules: linkedIssueHardRulesOverride, + unlinkedIssueGuardrail: unlinkedIssueGuardrailOverride, ...restManifestSettings } = manifest.settings; const effective: RepositorySettings = { ...dbSettings, ...restManifestSettings }; @@ -2212,6 +2230,13 @@ export function resolveEffectiveSettings( closeDelaySeconds: linkedIssueHardRulesOverride.closeDelaySeconds ?? base.closeDelaySeconds, }; } + if (unlinkedIssueGuardrailOverride !== undefined) { + const base = dbSettings.unlinkedIssueGuardrail ?? DEFAULT_UNLINKED_ISSUE_GUARDRAIL; + effective.unlinkedIssueGuardrail = { + mode: unlinkedIssueGuardrailOverride.mode ?? base.mode, + minConfidence: unlinkedIssueGuardrailOverride.minConfidence ?? base.minConfidence, + }; + } applyGateConfigOverrides(effective, manifest.gate); // The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the // boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797). diff --git a/src/signals/unlinked-issue-candidates.ts b/src/signals/unlinked-issue-candidates.ts new file mode 100644 index 0000000000..cee5e69b6c --- /dev/null +++ b/src/signals/unlinked-issue-candidates.ts @@ -0,0 +1,97 @@ +// Deterministic pre-filter for the unlinked-issue guardrail (#unlinked-issue-guardrail). PURE — no IO, no +// AI call — so it can run on every unlinked PR for free and only hand a SHORT, bounded candidate list to the +// expensive AI verifier (src/review/unlinked-issue-match.ts), which is the actual precision gate. This stage +// is deliberately RECALL-oriented (a coincidental token/path overlap is cheap to false-positive here — the AI +// step is what must be accurate), never the reverse: it must never silently drop a genuinely-matching issue +// just to save an AI call. + +export type CandidateOpenIssue = { + number: number; + title: string; + body: string | null; + labels: string[]; +}; + +export type UnlinkedIssueCandidateMatch = { + issue: CandidateOpenIssue; + score: number; + matchedTokens: string[]; + pathMentioned: boolean; +}; + +export type FindUnlinkedIssueCandidatesInput = { + prTitle: string; + prBody: string | null | undefined; + changedPaths: string[]; + openIssues: CandidateOpenIssue[]; +}; + +// Bound the AI-verifier fan-out per PR: even a repo with hundreds of open issues only ever sends its +// top-scoring handful for a real (paid/self-host-compute) AI call. +const MAX_CANDIDATES = 3; +// A path/basename mention is a much stronger signal than shared vocabulary — worth several tokens' score, +// and (deliberately) enough on its own to qualify a candidate even with zero token overlap (an issue that +// names the exact file this PR touches is worth checking regardless of shared wording). +const PATH_MENTION_SCORE_BONUS = 5; +// Token overlap alone only qualifies a candidate once it clears this bar — a single shared common word +// (even after stopword filtering) is not enough evidence to spend an AI call on. +const MIN_TOKEN_OVERLAP = 3; +// Tokens shorter than this are dropped before counting — short tokens (case IDs, "PR", "fix") are too +// common across unrelated issues to be distinctive evidence of a real match. +const MIN_TOKEN_LENGTH = 4; + +// A small, curated stopword list for the vocabulary shared by nearly every PR/issue description +// regardless of topic — without this, "this PR fixes the issue where..." style boilerplate would dominate +// the token-overlap score and swamp genuinely distinctive words. +const STOPWORDS = new Set([ + "this", "that", "with", "from", "have", "when", "where", "which", "there", "their", + "issue", "issues", "should", "would", "could", "about", "would", "into", "your", "were", + "then", "than", "will", "does", "doesn", "cannot", "currently", "instead", "because", + "these", "those", "being", "only", "also", "still", "even", "some", "each", "such", +]); + +function tokenize(text: string): Set { + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(token)); + return new Set(tokens); +} + +/** True when an issue's body names one of the PR's changed files — either the full repo-relative path or + * just its basename (issues commonly reference "the X.ts file" without the full path). Basenames shorter + * than {@link MIN_TOKEN_LENGTH} are skipped as too generic (e.g. `db.ts`, `index.ts` collide across repos). */ +function issueMentionsChangedPath(issueBody: string, changedPaths: string[]): boolean { + const lowerBody = issueBody.toLowerCase(); + return changedPaths.some((path) => { + const lowerPath = path.toLowerCase(); + if (lowerBody.includes(lowerPath)) return true; + const basename = lowerPath.slice(lowerPath.lastIndexOf("/") + 1); + return basename.length >= MIN_TOKEN_LENGTH && lowerBody.includes(basename); + }); +} + +/** + * Rank a repo's open issues by how strongly they overlap an unlinked PR, returning at most + * {@link MAX_CANDIDATES} qualifying matches (highest score first, ties broken by lower issue number — + * the earlier-filed issue is the more likely original target). An issue qualifies via EITHER a + * distinctive-token overlap clearing {@link MIN_TOKEN_OVERLAP}, OR a changed-path mention in its body + * (see {@link issueMentionsChangedPath}) — either alone is sufficient. Returns `[]` when nothing qualifies; + * this function never calls out to AI or GitHub, so a repo with no genuine candidates costs nothing beyond + * this pass. + */ +export function findUnlinkedIssueCandidates(input: FindUnlinkedIssueCandidatesInput): UnlinkedIssueCandidateMatch[] { + const prTokens = tokenize(`${input.prTitle} ${input.prBody ?? ""}`); + const matches: UnlinkedIssueCandidateMatch[] = []; + for (const issue of input.openIssues) { + const issueBody = issue.body ?? ""; + const issueTokens = tokenize(`${issue.title} ${issueBody}`); + const matchedTokens = [...prTokens].filter((token) => issueTokens.has(token)); + const pathMentioned = issueBody.length > 0 && issueMentionsChangedPath(issueBody, input.changedPaths); + if (matchedTokens.length < MIN_TOKEN_OVERLAP && !pathMentioned) continue; + const score = matchedTokens.length + (pathMentioned ? PATH_MENTION_SCORE_BONUS : 0); + matches.push({ issue, score, matchedTokens, pathMentioned }); + } + matches.sort((a, b) => b.score - a.score || a.issue.number - b.issue.number); + return matches.slice(0, MAX_CANDIDATES); +} diff --git a/src/types.ts b/src/types.ts index c9d9f0ba25..2107b70555 100644 --- a/src/types.ts +++ b/src/types.ts @@ -779,6 +779,10 @@ export type RepositorySettings = { * contributor PRs that link ineligible issues before spending AI review budget: owner/other-assigned, * maintainer-only, or missing point-label issues. Defaults all-off so self-hosters opt into their own policy. */ linkedIssueHardRules?: LinkedIssueHardRulesConfig | undefined; + /** Same-account issue-avoidance guardrail (#unlinked-issue-guardrail). Config-as-code only; set with + * `.gittensory.yml settings.unlinkedIssueGuardrail` in private/global or per-repo config. Defaults + * all-off so a self-hoster opts into their own credibility-gate-farming defense. */ + unlinkedIssueGuardrail?: UnlinkedIssueGuardrailConfig | undefined; publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; @@ -1009,6 +1013,20 @@ export type LinkedIssueHardRulesConfig = { closeDelaySeconds: number; }; +/** "hold" evaluates a linkless PR against the repo's open issues and HOLDS it for manual review on a + * verified direct match (never auto-closes); "off" (default) never runs the check. */ +export type UnlinkedIssueGuardrailMode = "hold" | "off"; + +/** Same-account issue-avoidance guardrail (#unlinked-issue-guardrail, credibility-gate-farming defense): + * when a PR links NO issue, check whether it directly, unambiguously solves an EXISTING open issue that + * was never linked. Config-as-code only, `.gittensory.yml settings.unlinkedIssueGuardrail`; defaults + * all-off so a self-hoster opts in per repo. `minConfidence` bounds false positives — the AI verifier + * must clear this bar (0-1) before a match holds anything. */ +export type UnlinkedIssueGuardrailConfig = { + mode: UnlinkedIssueGuardrailMode; + minConfidence: number; +}; + /** A blocked contributor (#1425, anti-abuse): a GitHub `login` plus optional maintainer metadata. The converged * engine short-circuits a blacklisted author's PR/issue to a deterministic close ahead of any merit/CI/AI * analysis. Metadata can come from private configuration and must not be echoed to public surfaces. */ diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index 506ee406b2..dc09510cc3 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -490,6 +490,151 @@ describe("planAgentMaintenanceActions (#778)", () => { }); }); + describe("unlinked-issue-match hold (#unlinked-issue-guardrail, credibility-gate-farming defense)", () => { + const matched = { unlinkedIssueMatchHold: { reason: "this PR links no issue, but appears to directly solve open issue #42 without linking it (adds the missing dedup key)", comment: "This PR doesn't link an issue, but its diff appears to directly solve #42. Please add a linking reference." } }; + + it("does NOT auto-merge a clean+approved+passing PR when a confirmed unlinked-issue match is found", () => { + const plan = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, ...matched, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } }))); + expect(plan).not.toContain("merge"); + }); + + it("labels the PR manual-review (the generic label — there is no dedicated one) with the match reason", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { review_state_label: "auto", merge: "auto" }, ...matched, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_NEEDS_REVIEW); + expect(label?.reason).toContain("open issue #42"); + expect(classes(plan)).not.toContain("merge"); + }); + + it("attaches the linking-reference comment to the manual-review label action", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { review_state_label: "auto" }, ...matched, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.comment).toContain("directly solve #42"); + }); + + it("falls back to manual-review (+ the comment) when review_state_label is OFF", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, ...matched, pr: { labels: [], mergeableState: "clean" } })); + expect(plan).toEqual([expect.objectContaining({ actionClass: "label", autonomyClass: "merge", label: AGENT_LABEL_NEEDS_REVIEW, labelOp: "add", comment: matched.unlinkedIssueMatchHold.comment })]); + }); + + it("does not duplicate manual-review when review_state_label IS also acting (only one label action)", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", review_state_label: "auto" }, ...matched, pr: { labels: [], mergeableState: "clean" } })); + expect(plan.filter((a) => a.actionClass === "label")).toHaveLength(1); + }); + + it("does not re-plan the manual-review label when the PR already carries it (idempotent)", () => { + const plan = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { review_state_label: "auto" }, ...matched, pr: { labels: [AGENT_LABEL_NEEDS_REVIEW] } }))); + expect(plan).not.toContain("label"); + }); + + it("an explicit null manualReviewLabel disables the fallback (respects the operator's own opt-out)", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, manualReviewLabel: null, ...matched, pr: { labels: [], mergeableState: "clean" } })); + expect(plan).toEqual([]); + }); + + it("a BLOCKING PR keeps the changes-requested label even with a confirmed match present (blocker wins, no comment)", () => { + const label = planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { review_state_label: "auto" }, blockerTitles: ["x"], ...matched, pr: { labels: [] } })).find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_CHANGES); + expect(label?.comment).toBeUndefined(); + }); + + it("a live migration collision takes priority over an unlinked-issue-match hold when both are true simultaneously", () => { + const plan = planAgentMaintenanceActions(input({ + conclusion: "success", + autonomy: { review_state_label: "auto" }, + migrationCollisionHold: { reason: "live migrations/** collision on main", comment: "Please rebase." }, + ...matched, + pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" }, + })); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_MIGRATION_COLLISION); + expect(label?.comment).toBe("Please rebase."); + }); + + it("still auto-merges when no unlinked-issue-match hold is present (absent input, byte-identical to today)", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { review_state_label: "auto", merge: "auto" }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(classes(plan)).toContain("merge"); + expect(plan.find((a) => a.actionClass === "label")?.label).toBe(AGENT_LABEL_READY); + }); + }); + + describe("unlinked-issue-match CLOSE — confirmed repeat by the same contributor (#unlinked-issue-guardrail-followup)", () => { + const repeated = { unlinkedIssueMatchClose: { reason: "this PR appears to directly solve open issue #42 without linking it — a repeat of the same unlinked-issue pattern already flagged on an earlier PR from this contributor", comment: "Closing: please link the issue you're solving going forward." } }; + + it("closes one-shot even on a green, clean, approved PR — takes precedence over merge, and pins expectedHeadSha", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", close: "auto" }, ...repeated, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED", headSha: "abc123" } })); + expect(classes(plan)).toContain("close"); + expect(classes(plan)).not.toContain("merge"); + expect(plan.find((a) => a.actionClass === "close")?.expectedHeadSha).toBe("abc123"); + }); + + it("cites the repeat-specific reason and the standard close message template, tagged closeKind: heuristic (subject to the precision breaker)", () => { + const action = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { close: "auto" }, ...repeated, pr: { labels: [] } })).find((a) => a.actionClass === "close"); + expect(action?.reason).toContain("#42"); + expect(action?.reason).toContain("repeat"); + expect(action?.closeKind).toBe("heuristic"); + expect(action?.closeConcreteEvidence).not.toBe(true); + expect(action?.closeComment).toContain("Gittensory is closing this pull request"); + expect(action?.closeComment).toContain("#42"); + }); + + it("does not close when the close autonomy class is not acting (deny-by-default floor)", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: {}, ...repeated, pr: { labels: [] } })); + expect(plan).toEqual([]); + }); + + it("HOLDS (never silently merges) a confirmed repeat when merge is auto but close autonomy is not acting (gate-review finding)", () => { + // Before the fix: heldForManualReview ignored unlinkedIssueMatchClose entirely, so with close autonomy + // not configured, an otherwise clean/green/approved PR would silently MERGE straight past a confirmed + // repeat offender -- worse than doing nothing. + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, ...repeated, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(classes(plan)).not.toContain("merge"); + expect(classes(plan)).not.toContain("close"); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_NEEDS_REVIEW); + expect(label?.reason).toContain("#42"); + expect(label?.comment).toContain("Closing:"); + }); + + it("still holds (not merges) the fallback case when review_state_label is ALSO acting, without duplicating the label", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", review_state_label: "auto" }, ...repeated, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(classes(plan)).not.toContain("merge"); + expect(plan.filter((a) => a.actionClass === "label")).toHaveLength(1); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_NEEDS_REVIEW); + expect(label?.reason).toContain("#42"); + expect(label?.comment).toContain("Closing:"); + }); + + it("an explicit null manualReviewLabel disables the fallback hold-label (respects the operator's own opt-out) -- the PR still never merges", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, manualReviewLabel: null, ...repeated, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(plan).toEqual([]); + }); + + it("does not close an owner-authored PR (owner exemption applies the same as every other close path)", () => { + const plan = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { close: "auto" }, ...repeated, authorIsOwner: true, pr: { labels: [] } }))); + expect(plan).not.toContain("close"); + }); + + it("takes precedence over a linked-issue hard-rule violation's own close reason when both are somehow true (linked-issue-hard-rule wins, per the existing disposition order)", () => { + // Documents the existing precedence, not a new requirement: willCloseForLinkedIssue is checked BEFORE + // unlinkedIssueMatchViolated in the disposition chain, so a deterministic hard-rule close still wins. + const plan = planAgentMaintenanceActions(input({ + conclusion: "success", + autonomy: { close: "auto" }, + linkedIssueHardRule: { violated: true, reason: "Linked issue #1 is assigned to the maintainer." }, + ...repeated, + pr: { labels: [] }, + })).find((a) => a.actionClass === "close"); + expect(plan?.closeKind).toBe("linked-issue-hard-rule"); + }); + + it("still auto-merges when no unlinked-issue-match close is present (absent input, byte-identical to today)", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(classes(plan)).toContain("merge"); + }); + }); + describe("submission volume is NOT a manual-hold reason — only guardrail paths hold (#minimize-manual)", () => { it("a high-volume author's clean+green+approved PR MERGES (the quality gate, not a submission count, is the defense)", () => { const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", approve: "auto", close: "auto", review_state_label: "auto" }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); @@ -907,6 +1052,13 @@ describe("planAgentMaintenanceActions (#778)", () => { // labeled changes-requested, not ready-to-merge expect(plan.find((a) => a.actionClass === "label")?.label).toBe(AGENT_LABEL_CHANGES); }); + + it("falls back to a generic reason when the hard-rule violation carries no reason string", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { close: "auto", review_state_label: "auto" }, ciState: "passed", linkedIssueHardRule: { violated: true, reason: null }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + const label = plan.find((a) => a.actionClass === "label"); + expect(label?.label).toBe(AGENT_LABEL_CHANGES); + expect(label?.reason).toContain("linked-issue hard rule: ineligible linked issue"); + }); }); describe("linked-issue flag-then-close double-check (#linked-issue-verify-before-close)", () => { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index ae1b73c9ef..56dcd3c4bf 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -2299,6 +2299,36 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(parsed.warnings.some((w) => w.includes("settings.linkedIssueHardRules.closeDelaySeconds"))).toBe(true); }); + it("wires settings.unlinkedIssueGuardrail into the manifest parser as a sparse override", () => { + const parsed = parseFocusManifest({ settings: { unlinkedIssueGuardrail: { mode: "hold", minConfidence: 0.7 } } }); + expect(parsed.settings.unlinkedIssueGuardrail).toEqual({ mode: "hold", minConfidence: 0.7 }); + expect(parsed.warnings).toEqual([]); + }); + + it("resolveEffectiveSettings merges a partial unlinkedIssueGuardrail override without clearing the lower-layer minConfidence", () => { + const db = { unlinkedIssueGuardrail: { mode: "off", minConfidence: 0.95 } } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { unlinkedIssueGuardrail: { mode: "hold" } } })); + expect(eff.unlinkedIssueGuardrail).toEqual({ mode: "hold", minConfidence: 0.95 }); + }); + + it("resolveEffectiveSettings merges a minConfidence-only unlinkedIssueGuardrail override without clearing the lower-layer mode", () => { + const db = { unlinkedIssueGuardrail: { mode: "hold", minConfidence: 0.85 } } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { unlinkedIssueGuardrail: { minConfidence: 0.7 } } })); + expect(eff.unlinkedIssueGuardrail).toEqual({ mode: "hold", minConfidence: 0.7 }); + }); + + it("drops a malformed unlinkedIssueGuardrail.minConfidence field instead of replacing existing policy with defaults", () => { + const parsed = parseFocusManifest({ settings: { unlinkedIssueGuardrail: { mode: "hold", minConfidence: 5 } } }); + expect(parsed.settings.unlinkedIssueGuardrail).toEqual({ mode: "hold" }); + expect(parsed.warnings.some((w) => w.includes("settings.unlinkedIssueGuardrail.minConfidence"))).toBe(true); + }); + + it("warns and ignores a malformed top-level unlinkedIssueGuardrail value", () => { + const parsed = parseFocusManifest({ settings: { unlinkedIssueGuardrail: "oops" } }); + expect(parsed.settings.unlinkedIssueGuardrail).toBeUndefined(); + expect(parsed.warnings).toContain(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`); + }); + it("parses aiReview from settings: and lets gate.aiReview win in resolveEffectiveSettings", () => { const parsed = parseFocusManifest({ settings: { aiReviewMode: "advisory", aiReviewByok: true } }); expect(parsed.settings.aiReviewMode).toBe("advisory"); diff --git a/test/unit/linked-issue-hard-rules.test.ts b/test/unit/linked-issue-hard-rules.test.ts index d9aa65a80a..e234132f86 100644 --- a/test/unit/linked-issue-hard-rules.test.ts +++ b/test/unit/linked-issue-hard-rules.test.ts @@ -4,11 +4,14 @@ import * as backfillModule from "../../src/github/backfill"; import { DEFAULT_LINKED_ISSUE_HARD_RULES, evaluateLinkedIssueHardRules, + hasVerifiableOpenLinkedIssueReference, loadLinkedIssueHardRules, resolveLinkedIssueHardRule, + resolveLinkedIssueHasOpenReference, type LinkedIssueFacts, type LinkedIssueHardRulesConfig, } from "../../src/review/linked-issue-hard-rules"; +import type { LinkedIssueFactsFetch } from "../../src/github/backfill"; import { normalizeLinkedIssueHardRulesConfig } from "../../src/review/linked-issue-hard-rules-config"; import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; import { setLocalManifestReader } from "../../src/signals/focus-manifest-loader"; @@ -497,3 +500,95 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () = expect(resolveEffectiveSettings(db, parseFocusManifest(null)).linkedIssueGateMode).toBe("advisory"); }); }); + +describe("hasVerifiableOpenLinkedIssueReference (#unlinked-issue-guardrail-followup — pure evaluator)", () => { + const found = (state: string): LinkedIssueFactsFetch => ({ status: "found", facts: { number: 1, state, labels: [], assignees: [], authorLogin: null } }); + const notFound: LinkedIssueFactsFetch = { status: "not_found" }; + const fetchError: LinkedIssueFactsFetch = { status: "fetch_error" }; + + it("fails open (true) on an empty input — the caller handles the zero-citation case separately", () => { + expect(hasVerifiableOpenLinkedIssueReference([])).toBe(true); + }); + + it("is true when at least one linked issue is confirmed open", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("open")])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), found("open")])).toBe(true); + }); + + it("is false when every linked issue conclusively resolves to NOT open (closed or confirmed-missing), with zero ambiguity", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("closed")])).toBe(false); + expect(hasVerifiableOpenLinkedIssueReference([notFound])).toBe(false); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), notFound])).toBe(false); + }); + + it("fails open (true) whenever ANY result is ambiguous (fetch_error), even if none are confirmed open", () => { + expect(hasVerifiableOpenLinkedIssueReference([fetchError])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), fetchError])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([notFound, fetchError])).toBe(true); + }); + + it("a confirmed-open result takes priority over an ambiguous one present in the same set", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("open"), fetchError])).toBe(true); + }); +}); + +describe("resolveLinkedIssueHasOpenReference (#unlinked-issue-guardrail-followup — live orchestration)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("returns true and fetches nothing when there are no linked issues", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [] }); + expect(result).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns true when the linked issue is confirmed open", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "open", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(true); + }); + + it("returns false when the linked issue is confirmed CLOSED — the exact stale-link gaming case", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "closed", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(false); + }); + + it("fails open (true) when the fetch errors transiently rather than confirming the issue is dead", async () => { + vi.stubGlobal("fetch", async () => new Response("server error", { status: 500 })); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(true); + }); + + it("still resolves correctly (via the public-token fallback) when no installationId is supplied at all", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "closed", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7], installationId: null }); + expect(result).toBe(false); + }); + + it("falls back to the public token (and still resolves) when installationId is set but token minting fails", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/app/installations/") ? new Response("forbidden", { status: 403 }) : input.toString().includes("/issues/") ? Response.json({ number: 7, state: "open", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7], installationId: 123 }); + expect(result).toBe(true); + }); + + it("checks multiple linked issues and is true when only one of several is open", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/issues/1")) return Response.json({ number: 1, state: "closed", labels: [], assignees: [] }); + if (url.endsWith("/issues/2")) return Response.json({ number: 2, state: "open", labels: [], assignees: [] }); + return new Response("missing", { status: 404 }); + }); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [1, 2] }); + expect(result).toBe(true); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index ed66f5d1b9..edae7513ee 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7077,6 +7077,117 @@ describe("queue processors", () => { }); }); + it("blocks under linkedIssueGateMode:block when the PR only cites an already-CLOSED issue (#unlinked-issue-guardrail-followup — the stale-link gaming case)", async () => { + // Before the fix, pr.linkedIssues.length > 0 alone satisfied this gate regardless of the cited issue's real + // state — a contributor could cite an already-closed (or fabricated) issue number to fake compliance. + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + }); + // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code) — mirrors the + // existing "publishes an opt-in gate..." test above, which needs the same manifest override for the raw + // DB setting to take effect as a live hard block. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/5") && !url.includes("/comments")) return Response.json({ number: 5, state: "closed", labels: [], assignees: [] }); + if (url.includes("/commits/gate124/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 901 }, { status: 201 }); + if (url.includes("/check-runs/901") && (init?.method ?? "GET") === "PATCH") return Response.json({ id: 901, html_url: "https://github.com/checks/901" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-stale-link", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 43, title: "Fake compliance", state: "open", user: { login: "contributor" }, head: { sha: "gate124" }, labels: [], body: "Closes #5" }, + }, + }); + + const summary = await env.DB.prepare("select conclusion from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 43, "gate124") + .first<{ conclusion: string }>(); + expect(summary?.conclusion).toBe("failure"); + }); + + it("does NOT block under linkedIssueGateMode:block when the cited issue is genuinely OPEN", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/5") && !url.includes("/comments")) return Response.json({ number: 5, state: "open", labels: [], assignees: [] }); + if (url.includes("/commits/gate125/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 902 }, { status: 201 }); + if (url.includes("/check-runs/902") && (init?.method ?? "GET") === "PATCH") { + const body = JSON.parse(String(init?.body ?? "{}")) as { conclusion?: string; output?: { title?: string } }; + expect(body.output?.title).not.toBe("Gittensory Orb Review Agent: No linked issue detected"); + return Response.json({ id: 902, html_url: "https://github.com/checks/902" }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-open-link", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 44, title: "Real link", state: "open", user: { login: "contributor" }, head: { sha: "gate125" }, labels: [], body: "Closes #5" }, + }, + }); + + const summary = await env.DB.prepare("select conclusion from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 44, "gate125") + .first<{ conclusion: string }>(); + expect(summary?.conclusion).not.toBe("failure"); + }); + it("accepts PR-body validation evidence for configured manifest test expectations", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( @@ -9643,6 +9754,128 @@ describe("queue processors", () => { }); }); + describe("unlinked-issue guardrail (#unlinked-issue-guardrail, credibility-gate-farming defense)", () => { + // Mirrors the #2550 migration-recheck fixture immediately above: full merge-eligible stub set + // (clean + green + approved) so a positive test proves the hold actually suppresses what would + // otherwise merge, and a negative test proves the guardrail correctly stays out of the way / off by + // default. `run` (the env.AI.run spy) is asserted directly rather than inferred from side effects. + function stubUnlinkedIssueGuardrailFetch(prNumber: number, seen: { closed: boolean; merged: boolean; labels: string[]; comments: string[] }) { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/check-runs/") && method === "PATCH") return Response.json({ id: 901 }); + if (/\/pulls\/\d+(?:\?|$)/.test(url) && method === "GET" && !url.includes(`/pulls/${prNumber}/`)) { + return Response.json({ number: prNumber, state: "open", user: { login: "contributor" }, head: { sha: "sha1" }, base: { ref: "main", sha: "base" }, mergeable_state: "clean", labels: [] }); + } + if (url.includes(`/pulls/${prNumber}/files`)) return Response.json([{ filename: "src/queue/webhook-retry.ts", status: "modified", additions: 5, deletions: 0, changes: 5, patch: "@@\n+dedupe retries" }]); + if (url.includes(`/commits/sha1/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes(`/commits/sha1/status`)) return Response.json({ state: "success", statuses: [{ context: "ci/build", state: "success", description: "ok" }] }); + if (url.includes(`/commits/sha1/check-suites`)) return Response.json({ check_suites: [] }); + if (url.includes("/branches/")) return Response.json({ contexts: [] }); + if (url === "https://api.github.com/graphql") return Response.json({ data: { repository: { pullRequest: { reviewDecision: "APPROVED" } } } }); + if (url.includes(`/pulls/${prNumber}/merge`) && method === "PUT") { + seen.merged = true; + return Response.json({ merged: true, sha: "merged-sha1" }); + } + if (url.includes(`/pulls/${prNumber}`) && method === "PATCH") { + seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; + return Response.json({ number: prNumber, state: "closed" }); + } + if (url.includes(`/issues/${prNumber}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${prNumber}/labels`) && method === "POST") { + seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + if (url.endsWith("/labels") && method === "POST") return Response.json({ name: "x" }, { status: 201 }); + if (url.includes(`/issues/${prNumber}/comments`) && method === "POST") { + seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); + return Response.json({ id: 1 }, { status: 201 }); + } + if (url.includes(`/issues/${prNumber}/comments`)) return Response.json([]); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 901 }, { status: 201 }); + return Response.json({}); + }); + } + + async function seedGuardrailRepo(env: Env, prNumber: number, opts: { guardrailMode?: "hold" | "off"; prBody?: string; autonomy?: Record } = {}) { + await upsertInstallation(env, { + installation: { id: 123, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: { contents: "write", pull_requests: "write", issues: "write" }, events: [] }, + }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 123); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: opts.autonomy ?? { merge: "auto", review_state_label: "auto" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + if (opts.guardrailMode !== undefined) { + await upsertRepoFocusManifest(env, "owner/repo", { settings: { unlinkedIssueGuardrail: { mode: opts.guardrailMode } } }); + } + await upsertIssueFromGitHub(env, "owner/repo", { number: 5, title: "webhook retry duplicate bug report", state: "open", user: { login: "someone" }, labels: [], body: "retries duplicate events under heavy load, needs a dedup key" }); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: prNumber, title: "fix webhook retry duplicate bug", state: "open", user: { login: "contributor" }, head: { sha: "sha1" }, base: { ref: "main" }, labels: [], body: opts.prBody ?? "" }); + } + + it("holds a would-otherwise-merge PR when its diff appears to directly solve an existing open issue it never linked", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify({ matched: true, confidence: 0.9, evidence: "adds the missing dedup key" }) })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai }); + await seedGuardrailRepo(env, 80, { guardrailMode: "hold" }); + const seen = { closed: false, merged: false, labels: [] as string[], comments: [] as string[] }; + stubUnlinkedIssueGuardrailFetch(80, seen); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "unlinked-issue-hold", repoFullName: "owner/repo", prNumber: 80, installationId: 123 }); + + expect(seen.merged).toBe(false); + expect(seen.closed).toBe(false); // held, never closed — this is a hold, not a close + expect(seen.labels).toContain("manual-review"); + expect(seen.comments.some((c) => c.includes("#5"))).toBe(true); + expect(run).toHaveBeenCalled(); + }); + + it("is off by default — never calls the AI even for a PR whose diff clearly overlaps an open issue", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify({ matched: true, confidence: 0.9, evidence: "x" }) })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai }); + await seedGuardrailRepo(env, 81); // guardrailMode left unset — defaults off + const seen = { closed: false, merged: false, labels: [] as string[], comments: [] as string[] }; + stubUnlinkedIssueGuardrailFetch(81, seen); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "unlinked-issue-off", repoFullName: "owner/repo", prNumber: 81, installationId: 123 }); + + expect(run).not.toHaveBeenCalled(); + expect(seen.merged).toBe(true); + }); + + it("does not call the AI when the PR already links an issue, even with the guardrail on", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify({ matched: true, confidence: 0.9, evidence: "x" }) })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai }); + await seedGuardrailRepo(env, 82, { guardrailMode: "hold", prBody: "Closes #5" }); + const seen = { closed: false, merged: false, labels: [] as string[], comments: [] as string[] }; + stubUnlinkedIssueGuardrailFetch(82, seen); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "unlinked-issue-already-linked", repoFullName: "owner/repo", prNumber: 82, installationId: 123 }); + + expect(run).not.toHaveBeenCalled(); + expect(seen.merged).toBe(true); + }); + + it("escalates to a CLOSE on a second confirmed match by the same contributor (#unlinked-issue-guardrail-followup)", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify({ matched: true, confidence: 0.9, evidence: "adds the missing dedup key" }) })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai }); + + await seedGuardrailRepo(env, 90, { guardrailMode: "hold" }); + const seenFirst = { closed: false, merged: false, labels: [] as string[], comments: [] as string[] }; + stubUnlinkedIssueGuardrailFetch(90, seenFirst); + await processJob(env, { type: "agent-regate-pr", deliveryId: "unlinked-issue-repeat-first", repoFullName: "owner/repo", prNumber: 90, installationId: 123 }); + expect(seenFirst.closed).toBe(false); // first confirmed match: held, not closed + expect(seenFirst.merged).toBe(false); + + // The second PR needs `close` autonomy acting for the escalated disposition to actually execute as a + // close (the first PR's hold path only ever needs `merge`/`review_state_label`). + await seedGuardrailRepo(env, 91, { guardrailMode: "hold", autonomy: { merge: "auto", review_state_label: "auto", close: "auto" } }); + const seenSecond = { closed: false, merged: false, labels: [] as string[], comments: [] as string[] }; + stubUnlinkedIssueGuardrailFetch(91, seenSecond); + await processJob(env, { type: "agent-regate-pr", deliveryId: "unlinked-issue-repeat-second", repoFullName: "owner/repo", prNumber: 91, installationId: 123 }); + expect(seenSecond.closed).toBe(true); // same contributor's SECOND confirmed match: closed + expect(seenSecond.merged).toBe(false); + }); + }); + describe("force-fresh-rebase-before-merge gate (#2552)", () => { // Full merge-eligible stub set (clean + green + approved), reused across scenarios — mirrors the #2550 // migration-recheck fixture above. `baseAdvancedAt` stubs the NEW /commits/{baseRef} freshness read; diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index b52e48071b..da9765e6a0 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -73,6 +73,68 @@ describe("advisory rules", () => { expect(advisory.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); }); + it("does NOT flag a cited-but-unverified linked issue as missing (byte-identical to today when the caller hasn't confirmed it's dead)", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 13, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [7], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + expect(advisory.findings.map((finding) => finding.code)).not.toContain("missing_linked_issue"); + }); + + it("flags a linked issue as missing when the caller has confirmed none of the citations resolve to an open issue (#unlinked-issue-guardrail-followup)", () => { + // pr.linkedIssues is populated by a pure body-text regex that never checks the cited issue's real state -- + // a contributor can otherwise satisfy `requireLinkedIssue`/`linkedIssueGateMode: block` by citing an + // already-CLOSED (or fabricated) issue number. confirmedNoOpenLinkedIssue is the caller's live-verified + // signal that every citation is conclusively dead. + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 14, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [7], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true, confirmedNoOpenLinkedIssue: true }); + + expect(advisory.conclusion).toBe("neutral"); + const finding = advisory.findings.find((f) => f.code === "missing_linked_issue"); + expect(finding).toBeDefined(); + expect(finding?.detail).toContain("could not be verified as a currently open issue"); + }); + + it("confirmedNoOpenLinkedIssue is a no-op when the PR links nothing at all (the existing zero-citation path still drives the finding, with its own detail text)", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 15, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true, confirmedNoOpenLinkedIssue: true }); + + const finding = advisory.findings.find((f) => f.code === "missing_linked_issue"); + expect(finding?.detail).toBe("No closing reference or linked issue number was found in the PR metadata/body."); + }); + it("marks unknown repositories as action required", () => { const advisory = buildRepositoryAdvisory(null, "owner/repo"); expect(advisory.conclusion).toBe("action_required"); diff --git a/test/unit/unlinked-issue-candidates.test.ts b/test/unit/unlinked-issue-candidates.test.ts new file mode 100644 index 0000000000..06c7ec14af --- /dev/null +++ b/test/unit/unlinked-issue-candidates.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { findUnlinkedIssueCandidates, type CandidateOpenIssue } from "../../src/signals/unlinked-issue-candidates"; + +function issue(overrides: Partial & { number: number }): CandidateOpenIssue { + return { title: "", body: null, labels: [], ...overrides }; +} + +describe("findUnlinkedIssueCandidates", () => { + it("returns nothing when there are no open issues", () => { + expect(findUnlinkedIssueCandidates({ prTitle: "fix timeout handling", prBody: null, changedPaths: [], openIssues: [] })).toEqual([]); + }); + + it("returns nothing when token overlap is below the minimum and no path is mentioned", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "tweak formatting", + prBody: "minor cleanup", + changedPaths: ["src/utils/format.ts"], + openIssues: [issue({ number: 1, title: "unrelated topic entirely", body: "totally different subject matter" })], + }); + expect(result).toEqual([]); + }); + + it("qualifies via distinctive token overlap alone (no path mention)", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "fix timeout handling in webhook retry logic", + prBody: null, + changedPaths: [], + openIssues: [issue({ number: 1, title: "webhook retry logic times out under load", body: "the timeout handling needs a fix" })], + }); + expect(result).toHaveLength(1); + expect(result[0]?.issue.number).toBe(1); + expect(result[0]?.pathMentioned).toBe(false); + expect(result[0]?.matchedTokens.length).toBeGreaterThanOrEqual(3); + expect(result[0]?.score).toBe(result[0]?.matchedTokens.length); + }); + + it("qualifies via a full changed-path mention alone, even with zero token overlap", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "xyz", + prBody: "abc", + changedPaths: ["src/queue/processors.ts"], + openIssues: [issue({ number: 2, title: "totally unrelated wording", body: "something is wrong in src/queue/processors.ts specifically" })], + }); + expect(result).toHaveLength(1); + expect(result[0]?.pathMentioned).toBe(true); + expect(result[0]?.score).toBe(5); + }); + + it("qualifies via a basename-only mention when the basename is long enough", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "xyz", + prBody: "abc", + changedPaths: ["src/queue/processors.ts"], + openIssues: [issue({ number: 3, title: "bug", body: "processors.ts seems to double-count on retry" })], + }); + expect(result).toHaveLength(1); + expect(result[0]?.pathMentioned).toBe(true); + }); + + it("does not match on a too-short basename, regardless of body content", () => { + // basename "db" is only 2 chars (< MIN_TOKEN_LENGTH), so the length check short-circuits the match + // before ever scanning the body for it — too generic a fragment to trust as evidence. + const result = findUnlinkedIssueCandidates({ + prTitle: "xyz", + prBody: "abc", + changedPaths: ["src/db"], + openIssues: [issue({ number: 4, title: "bug", body: "db might be involved" })], + }); + expect(result).toEqual([]); + }); + + it("does not path-match when the issue body is empty (null body)", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "xyz", + prBody: "abc", + changedPaths: ["src/queue/processors.ts"], + openIssues: [issue({ number: 5, title: "processors.ts", body: null })], + }); + // title-only "processors.ts" token doesn't clear MIN_TOKEN_OVERLAP (only one token), and pathMentioned + // is false because the body (used for the path scan) is empty. + expect(result).toEqual([]); + }); + + it("combines token overlap AND a path mention into a higher score", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "fix retry loop duplicate counting bug", + prBody: null, + changedPaths: ["src/queue/processors.ts"], + openIssues: [issue({ number: 6, title: "retry loop duplicate counting", body: "reproduced in src/queue/processors.ts" })], + }); + expect(result).toHaveLength(1); + expect(result[0]?.pathMentioned).toBe(true); + expect(result[0]?.score).toBeGreaterThan(5); + }); + + it("ranks by score descending, then by lower issue number on a tie", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "fix retry loop duplicate counting bug", + prBody: null, + changedPaths: [], + openIssues: [ + issue({ number: 20, title: "retry loop duplicate counting problem", body: null }), + issue({ number: 5, title: "retry loop duplicate counting problem", body: null }), + ], + }); + expect(result.map((m) => m.issue.number)).toEqual([5, 20]); + }); + + it("caps the result at the top 3 qualifying candidates", () => { + const openIssues = Array.from({ length: 5 }, (_, i) => + issue({ number: i + 1, title: "webhook retry timeout handling logic", body: null }), + ); + const result = findUnlinkedIssueCandidates({ + prTitle: "fix webhook retry timeout handling logic", + prBody: null, + changedPaths: [], + openIssues, + }); + expect(result).toHaveLength(3); + }); + + it("filters out short tokens and stopwords from both the PR and issue text", () => { + // "the", "with", "into" are stopwords; "fix", "bug" are below MIN_TOKEN_LENGTH (4) or are stopwords — + // none of these should count toward the overlap even though they appear in both texts. + const result = findUnlinkedIssueCandidates({ + prTitle: "fix the bug with the retry logic into a queue", + prBody: null, + changedPaths: [], + openIssues: [issue({ number: 1, title: "the bug with into a queue and retry", body: null })], + }); + expect(result).toEqual([]); + }); + + it("treats an undefined PR body the same as a null one", () => { + const result = findUnlinkedIssueCandidates({ + prTitle: "fix webhook retry timeout handling logic", + prBody: undefined, + changedPaths: [], + openIssues: [issue({ number: 1, title: "webhook retry timeout handling logic", body: null })], + }); + expect(result).toHaveLength(1); + }); +}); diff --git a/test/unit/unlinked-issue-guardrail-config.test.ts b/test/unit/unlinked-issue-guardrail-config.test.ts new file mode 100644 index 0000000000..1a5bfa1251 --- /dev/null +++ b/test/unit/unlinked-issue-guardrail-config.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_UNLINKED_ISSUE_GUARDRAIL, + isUnlinkedIssueGuardrailMode, + normalizeUnlinkedIssueGuardrailConfig, +} from "../../src/review/unlinked-issue-guardrail-config"; + +describe("isUnlinkedIssueGuardrailMode", () => { + it("accepts the two valid modes", () => { + expect(isUnlinkedIssueGuardrailMode("hold")).toBe(true); + expect(isUnlinkedIssueGuardrailMode("off")).toBe(true); + }); + + it("rejects an invalid string and a non-string value", () => { + expect(isUnlinkedIssueGuardrailMode("block")).toBe(false); + expect(isUnlinkedIssueGuardrailMode(1)).toBe(false); + }); +}); + +describe("normalizeUnlinkedIssueGuardrailConfig", () => { + it("returns the all-off default when input is undefined (no warnings)", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig(undefined, warnings)).toEqual(DEFAULT_UNLINKED_ISSUE_GUARDRAIL); + expect(warnings).toEqual([]); + }); + + it("normalizes a fully-valid config", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ mode: "hold", minConfidence: 0.9 }, warnings)).toEqual({ + mode: "hold", + minConfidence: 0.9, + }); + expect(warnings).toEqual([]); + }); + + it("defaults mode when omitted", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 0.5 }, warnings).mode).toBe("off"); + expect(warnings).toEqual([]); + }); + + it("falls back to the default mode and warns on an invalid mode value", () => { + const warnings: string[] = []; + const cfg = normalizeUnlinkedIssueGuardrailConfig({ mode: "block" }, warnings); + expect(cfg.mode).toBe("off"); + expect(warnings).toEqual([`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "off".`]); + }); + + it("defaults minConfidence when omitted", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ mode: "hold" }, warnings).minConfidence).toBe(0.85); + expect(warnings).toEqual([]); + }); + + it.each([ + ["a non-number", "not-a-number"], + ["a negative number", -0.1], + ["a number above 1", 1.5], + ["NaN", Number.NaN], + ])("falls back to the default minConfidence and warns on %s", (_label, badValue) => { + const warnings: string[] = []; + const cfg = normalizeUnlinkedIssueGuardrailConfig({ minConfidence: badValue }, warnings); + expect(cfg.minConfidence).toBe(0.85); + expect(warnings).toEqual([`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "0.85".`]); + }); + + it("accepts the minConfidence boundary values 0 and 1", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 0 }, warnings).minConfidence).toBe(0); + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 1 }, warnings).minConfidence).toBe(1); + expect(warnings).toEqual([]); + }); + + it.each([ + ["an array", []], + ["null", null], + ["a string", "hold"], + ["a number", 1], + ])("normalizes a malformed top-level value (%s) back to the all-off default", (_label, badInput) => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig(badInput, warnings)).toEqual(DEFAULT_UNLINKED_ISSUE_GUARDRAIL); + expect(warnings).toEqual(["settings.unlinkedIssueGuardrail must be an object; using the default off policy."]); + }); +}); diff --git a/test/unit/unlinked-issue-guardrail.test.ts b/test/unit/unlinked-issue-guardrail.test.ts new file mode 100644 index 0000000000..978e8e72c0 --- /dev/null +++ b/test/unit/unlinked-issue-guardrail.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { upsertIssueFromGitHub, hasRecentAuditEvent } from "../../src/db/repositories"; +import { resolveUnlinkedIssueMatchDisposition, UNLINKED_ISSUE_MATCH_AUDIT_EVENT_TYPE } from "../../src/review/unlinked-issue-guardrail"; +import type { UnlinkedIssueGuardrailConfig } from "../../src/types"; + +function config(overrides: Partial = {}): UnlinkedIssueGuardrailConfig { + return { mode: "hold", minConfidence: 0.85, ...overrides }; +} + +function aiVerdict(overrides: Record = {}) { + return { matched: true, confidence: 0.9, evidence: "diff directly resolves the described bug", ...overrides }; +} + +async function seedIssue(env: Awaited>, number: number, title: string, body: string) { + await upsertIssueFromGitHub(env, "owner/repo", { number, title, state: "open", user: { login: "someone" }, labels: [], body }); +} + +const BASE_INPUT = { + repoFullName: "owner/repo", + linkedIssueCount: 0, + prTitle: "fix webhook retry duplicate bug", + prBody: null as string | null, + changedPaths: [] as string[], + diff: "diff", + prAuthorLogin: "contributor-a", +}; + +describe("resolveUnlinkedIssueMatchDisposition", () => { + it("returns undefined immediately when the guardrail mode is off, without any AI call", async () => { + const run = vi.fn(); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 1, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config({ mode: "off" }) }); + expect(result).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); + + it("returns undefined immediately when the PR already links an issue, without any AI call", async () => { + const run = vi.fn(); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 1, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config(), linkedIssueCount: 1 }); + expect(result).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); + + it("returns undefined when the repo has no open issues that qualify as candidates", async () => { + const run = vi.fn(); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 1, "completely unrelated topic", "nothing to do with this change at all"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(result).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); + + it("holds (kind: hold) with a comment citing the matched issue on a FIRST confirmed match, and records the occurrence", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(result?.kind).toBe("hold"); + expect(result?.reason).toContain("#7"); + expect(result?.reason).toContain("diff directly resolves the described bug"); + expect(result?.comment).toContain("Closes #7"); + // The occurrence is recorded for future repeat-detection, even on a first-time hold. + expect(await hasRecentAuditEvent(env, "contributor-a", UNLINKED_ISSUE_MATCH_AUDIT_EVENT_TYPE, "2000-01-01T00:00:00.000Z")).toBe(true); + }); + + it("omits the evidence parenthetical when the AI verdict has no evidence text", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict({ evidence: "" })) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(result).toEqual({ + kind: "hold", + reason: "this PR links no issue, but appears to directly solve open issue #7 without linking it", + comment: expect.stringContaining("Closes #7"), + }); + }); + + it("does not hold when the AI verdict is below the configured minConfidence", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict({ confidence: 0.5 })) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config({ minConfidence: 0.85 }) }); + expect(result).toBeUndefined(); + }); + + it("treats an issue with no body field at all (undefined, not null) as having empty body text", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + // No `body` key at all -> GitHubIssuePayload omits it -> IssueRecord.body is `undefined`, not `null`, + // exercising the `issue.body ?? null` fallback. Title-only token overlap is still enough to qualify. + await upsertIssueFromGitHub(env, "owner/repo", { + number: 12, + title: "webhook retry duplicate timeout handling logic bug", + state: "open", + user: { login: "someone" }, + labels: [], + }); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config(), prTitle: "fix webhook retry duplicate timeout handling logic" }); + expect(result?.reason).toContain("#12"); + }); + + it("falls through to the second candidate when the first is not a match", async () => { + const run = vi + .fn() + .mockResolvedValueOnce({ response: JSON.stringify(aiVerdict({ matched: false, confidence: 0.9 })) }) + .mockResolvedValueOnce({ response: JSON.stringify(aiVerdict({ matched: true, confidence: 0.95, evidence: "second issue is the real match" })) }); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + // Both issues score identically on tokens alone; #3 (lower number) is checked first by the pre-filter's + // tie-break, and its AI verdict comes back not-matched -- the orchestrator must still check #9. + await seedIssue(env, 3, "webhook retry duplicate bug report", "retries duplicate events under load, needs a dedup key"); + await seedIssue(env, 9, "webhook retry duplicate bug report", "retries duplicate events under load, needs a dedup key"); + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config(), prTitle: "fix webhook retry duplicate bug report" }); + expect(result?.reason).toContain("#9"); + expect(run).toHaveBeenCalledTimes(2); + }); + + describe("repeat-offense escalation (#unlinked-issue-guardrail-followup)", () => { + it("escalates to a CLOSE on a second confirmed match by the SAME contributor", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + const first = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(first?.kind).toBe("hold"); + + const second = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(second?.kind).toBe("close"); + expect(second?.reason).toContain("#7"); + expect(second?.reason).toContain("repeat"); + expect(second?.comment).toContain("already flagged"); + }); + + it("does NOT escalate a second match by a DIFFERENT contributor", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + const first = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: "contributor-a", config: config() }); + expect(first?.kind).toBe("hold"); + + const second = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: "contributor-b", config: config() }); + expect(second?.kind).toBe("hold"); + }); + + it("never escalates when the PR author is unknown (null), even across repeated calls", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + const first = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: null, config: config() }); + expect(first?.kind).toBe("hold"); + const second = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: null, config: config() }); + expect(second?.kind).toBe("hold"); + }); + + it("escalates a repeat even across DIFFERENT repos (the ledger is scoped to the contributor, not one repo)", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + await upsertIssueFromGitHub(env, "owner/other-repo", { number: 7, title: "webhook retry duplicate bug", state: "open", user: { login: "someone" }, labels: [], body: "retries duplicate events under load, needs a dedup key" }); + + const first = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(first?.kind).toBe("hold"); + + const second = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, repoFullName: "owner/other-repo", config: config() }); + expect(second?.kind).toBe("close"); + }); + + it("fails open (stays a hold, never throws) when the prior-match read itself errors", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/SELECT.*FROM.*audit_events/i.test(sql)) throw new Error("d1 down"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(result?.kind).toBe("hold"); + }); + + it("swallows a write failure when recording the occurrence — the hold/close verdict is unaffected", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/INSERT INTO.*audit_events/i.test(sql)) throw new Error("d1 down"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + const result = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + expect(result?.kind).toBe("hold"); + }); + + it("does not record an occurrence (and cannot escalate later) when the author login is only whitespace", async () => { + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + const first = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: " ", config: config() }); + expect(first?.kind).toBe("hold"); + const second = await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, prAuthorLogin: " ", config: config() }); + expect(second?.kind).toBe("hold"); + }); + }); +}); diff --git a/test/unit/unlinked-issue-match.test.ts b/test/unit/unlinked-issue-match.test.ts new file mode 100644 index 0000000000..97587d3ccb --- /dev/null +++ b/test/unit/unlinked-issue-match.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { __unlinkedIssueMatchInternals, verifyUnlinkedIssueMatch } from "../../src/review/unlinked-issue-match"; + +const { buildUserPrompt, parseVerdict } = __unlinkedIssueMatchInternals; + +const candidate = { number: 42, title: "webhook retries duplicate", body: "retries are duplicating events under load", labels: [] }; + +function verdictJson(overrides: Record = {}): string { + return JSON.stringify({ matched: true, confidence: 0.9, evidence: "diff adds the missing dedup key", ...overrides }); +} + +describe("verifyUnlinkedIssueMatch", () => { + it("fails closed (no AI call) when the AI binding is missing", async () => { + const env = createTestEnv({}); + await expect(verifyUnlinkedIssueMatch(env, { prTitle: "x", prBody: null, diff: "diff", candidate })).resolves.toEqual({ + matched: false, + confidence: 0, + evidence: "", + }); + }); + + it("fails closed when the AI binding has no run function", async () => { + const env = createTestEnv({ AI: {} as unknown as Ai }); + await expect(verifyUnlinkedIssueMatch(env, { prTitle: "x", prBody: null, diff: "diff", candidate })).resolves.toEqual({ + matched: false, + confidence: 0, + evidence: "", + }); + }); + + it("returns a matched verdict from the primary model", async () => { + const run = vi.fn(async () => ({ response: verdictJson() })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + const result = await verifyUnlinkedIssueMatch(env, { prTitle: "fix webhook retry dedup", prBody: null, diff: "diff", candidate }); + expect(result).toEqual({ matched: true, confidence: 0.9, evidence: "diff adds the missing dedup key" }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("returns a not-matched verdict when the model says so", async () => { + const run = vi.fn(async () => ({ response: verdictJson({ matched: false, evidence: "unrelated file" }) })); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + const result = await verifyUnlinkedIssueMatch(env, { prTitle: "x", prBody: null, diff: "diff", candidate }); + expect(result).toEqual({ matched: false, confidence: 0.9, evidence: "unrelated file" }); + }); + + it("falls back to the second model when the primary throws", async () => { + const run = vi.fn().mockRejectedValueOnce(new Error("primary down")).mockResolvedValueOnce({ response: verdictJson() }); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + const result = await verifyUnlinkedIssueMatch(env, { prTitle: "x", prBody: null, diff: "diff", candidate }); + expect(result.matched).toBe(true); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("fails closed when both the primary and fallback throw", async () => { + const run = vi.fn().mockRejectedValue(new Error("down")); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + const result = await verifyUnlinkedIssueMatch(env, { prTitle: "x", prBody: null, diff: "diff", candidate }); + expect(result).toEqual({ matched: false, confidence: 0, evidence: "" }); + expect(run).toHaveBeenCalledTimes(2); + }); +}); + +describe("parseVerdict", () => { + it("fails closed when the response has no JSON object at all", () => { + expect(parseVerdict("I cannot determine this.")).toEqual({ matched: false, confidence: 0, evidence: "" }); + }); + + it("fails closed when the extracted braces are not valid JSON", () => { + expect(parseVerdict("Sure: {matched: true, confidence: 1}")).toEqual({ matched: false, confidence: 0, evidence: "" }); + }); + + it("treats matched:true with a missing confidence as NOT matched (fail closed)", () => { + const result = parseVerdict(JSON.stringify({ matched: true, evidence: "looks related" })); + expect(result.matched).toBe(false); + expect(result.confidence).toBe(0); + }); + + it("treats matched:true with confidence exactly 0 as NOT matched", () => { + const result = parseVerdict(JSON.stringify({ matched: true, confidence: 0, evidence: "unsure" })); + expect(result.matched).toBe(false); + }); + + it("clamps a confidence below 0 up to 0", () => { + expect(parseVerdict(JSON.stringify({ matched: false, confidence: -0.4 })).confidence).toBe(0); + }); + + it("clamps a confidence above 1 down to 1", () => { + expect(parseVerdict(JSON.stringify({ matched: true, confidence: 5 })).confidence).toBe(1); + }); + + it("defaults evidence to an empty string when it is not a string", () => { + expect(parseVerdict(JSON.stringify({ matched: true, confidence: 0.7, evidence: 123 })).evidence).toBe(""); + }); + + it("extracts the JSON object even with surrounding prose", () => { + const result = parseVerdict(`Here is my analysis.\n${verdictJson()}\nThanks.`); + expect(result.matched).toBe(true); + }); +}); + +describe("buildUserPrompt", () => { + it("renders (empty) for a null PR body and a null candidate body", () => { + const prompt = buildUserPrompt({ prTitle: "t", prBody: null, diff: "d", candidate: { number: 1, title: "i", body: null, labels: [] } }); + expect(prompt).toContain("PULL REQUEST BODY: (empty)"); + expect(prompt).toContain("ISSUE BODY: (empty)"); + }); + + it("renders trimmed content for a non-empty PR body and candidate body", () => { + const prompt = buildUserPrompt({ prTitle: "t", prBody: " real body ", diff: "d", candidate: { number: 1, title: "i", body: " real issue body ", labels: [] } }); + expect(prompt).toContain("PULL REQUEST BODY: real body"); + expect(prompt).toContain("ISSUE BODY: real issue body"); + }); + + it("passes a short diff through unchanged", () => { + const prompt = buildUserPrompt({ prTitle: "t", prBody: null, diff: "short diff", candidate: { number: 1, title: "i", body: null, labels: [] } }); + expect(prompt).toContain("PULL REQUEST DIFF:\nshort diff"); + expect(prompt).not.toContain("truncated"); + }); + + it("truncates a diff over the char budget", () => { + const bigDiff = "x".repeat(7_000); + const prompt = buildUserPrompt({ prTitle: "t", prBody: null, diff: bigDiff, candidate: { number: 1, title: "i", body: null, labels: [] } }); + expect(prompt).toContain("… (diff truncated)"); + expect(prompt.length).toBeLessThan(bigDiff.length + 500); + }); +});