diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0f0f65b03a..2468f3613d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -343,6 +343,7 @@ import { buildQueueHealth, buildRoleContext, detectGittensorContributor, + hasClearNoIssueRationale, PR_PANEL_RETRIGGER_MARKER, type ContributorProfile, } from "../signals/engine"; @@ -8671,6 +8672,7 @@ async function maybePublishPrPublicSurface( testFileCount: manifestFiles.filter((file) => isTestPath(file.path)) .length, passedValidationCount: hasValidationNote(pr.body ?? "") ? 1 : 0, + hasNoIssueRationale: hasClearNoIssueRationale(pr), }); const policyCodes = new Set([ "manifest_blocked_path", diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 03f25522f5..dfb79f23df 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -2556,7 +2556,7 @@ export function buildPreflightResult( action: maintainerAuthored ? "No action." : "Refresh registry data or choose a registered active repo.", }); } - if (linkedIssues.length === 0 && lane.lane !== "issue_discovery") { + if (linkedIssues.length === 0 && lane.lane !== "issue_discovery" && !hasClearNoIssueRationale({ title: input.title, body: input.body })) { findings.push({ code: "missing_linked_issue", severity: "warning", @@ -2767,7 +2767,7 @@ export function buildPullRequestMaintainerPacket(args: { detail: "Gittensory does not have this pull request in the local cache.", }); } else { - if (pr.linkedIssues.length === 0) { + if (pr.linkedIssues.length === 0 && !hasClearNoIssueRationale(pr)) { findings.push({ code: "missing_linked_issue", severity: "warning", diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 7127576653..de3ff8d6e5 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -587,11 +587,17 @@ export function buildFocusManifestGuidance(args: { linkedIssueCount?: number | undefined; testFileCount?: number | undefined; passedValidationCount?: number | undefined; + // Caller-computed (via hasClearNoIssueRationale in ../signals/engine, not imported here to avoid a + // circular dependency -- engine.ts already imports FocusManifest types from this module): a linked-issue- + // required/preferred manifest policy must not keep flagging a PR whose body already explains why no + // issue is linked, same exemption the "Linked issue" review-panel signal already applies. + hasNoIssueRationale?: boolean | undefined; }): FocusManifestGuidance { const { manifest } = args; const changedPaths = args.changedPaths.filter((path) => typeof path === "string" && path.length > 0); const labels = (args.labels ?? []).map((label) => label.toLowerCase()); const linkedIssueCount = Math.max(0, args.linkedIssueCount ?? 0); + const hasNoIssueRationale = args.hasNoIssueRationale ?? false; const testFileCount = Math.max(0, args.testFileCount ?? 0); const passedValidationCount = Math.max(0, args.passedValidationCount ?? 0); @@ -651,7 +657,7 @@ export function buildFocusManifestGuidance(args: { publicNextSteps.push(`Consider a maintainer-preferred label (${manifest.preferredLabels.slice(0, 3).join(", ")}).`); } - if (manifest.linkedIssuePolicy === "required" && linkedIssueCount === 0) { + if (manifest.linkedIssuePolicy === "required" && linkedIssueCount === 0 && !hasNoIssueRationale) { findings.push({ code: "manifest_linked_issue_required", severity: "warning", diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 1f69449aa1..dcf77c9421 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -10,6 +10,7 @@ import { buildQueueHealth, buildRepoFitRecommendation, buildRoleContext, + hasClearNoIssueRationale, type ContributorOutcomeHistory, type ContributorProfile, type ContributorScoringProfile, @@ -308,6 +309,7 @@ export function buildLocalBranchAnalysis(args: { linkedIssueCount: preflight.linkedIssues.length, testFileCount: testFiles.length, passedValidationCount: validationSummary.passed, + hasNoIssueRationale: hasClearNoIssueRationale({ title, body: args.input.body }), }); const localFindings = [ ...buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness, githubBranchStatus, scorePreview.branchEligibility), diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index bcc97ea0cd..48bc13e80a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -583,6 +583,11 @@ describe("buildFocusManifestGuidance", () => { expect(guidance.findings.some((finding) => finding.code === "manifest_linked_issue_required")).toBe(true); }); + it("REGRESSION (#no-issue-rationale-exemption): does not require a linked issue when the caller reports a clear no-issue rationale", () => { + const guidance = buildFocusManifestGuidance({ manifest: wanted, changedPaths: ["src/x.ts"], linkedIssueCount: 0, testFileCount: 1, hasNoIssueRationale: true }); + expect(guidance.findings.some((finding) => finding.code === "manifest_linked_issue_required")).toBe(false); + }); + it("prefers a linked issue under the preferred policy", () => { const manifest = parseFocusManifest({ wantedPaths: ["src/"], linkedIssuePolicy: "preferred" }); const guidance = buildFocusManifestGuidance({ manifest, changedPaths: ["src/x.ts"], linkedIssueCount: 0, testFileCount: 1 }); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index b1b70e7bc1..2fead07fcc 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -269,7 +269,9 @@ describe("signal coverage edge cases", () => { expect(cleanPacket.suggestedActions).toEqual(["Queue looks manageable from cached Gittensory signals."]); expect(local.status).toBe("ready"); expect(local.localDiff).toMatchObject({ codeFileCount: 1, testFileCount: 1, inferredLinkedIssues: [1] }); - expect(directNoIssue.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); + // "No issue: typo fix" is a clear no-issue rationale (#no-issue-rationale-exemption) -- no + // missing_linked_issue finding despite zero linked issues. + expect(directNoIssue.findings.map((finding) => finding.code)).not.toContain("missing_linked_issue"); expect(outsideUnknownLane).toMatchObject({ status: "hold" }); expect(outsideUnknownLane.findings.find((finding) => finding.code === "lane_not_recommended")).toMatchObject({ severity: "warning", @@ -282,6 +284,15 @@ describe("signal coverage edge cases", () => { }); }); + it("REGRESSION (#no-issue-rationale-exemption): missing_linked_issue only fires without a clear no-issue rationale", () => { + const directRepo = repo("owner/direct"); + const noRationale = buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix pagination", body: "Just a fix, no context." }, directRepo, [], []); + const withRationale = buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix pagination", body: "No issue: internal cleanup only." }, directRepo, [], []); + + expect(noRationale.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); + expect(withRationale.findings.map((finding) => finding.code)).not.toContain("missing_linked_issue"); + }); + it("recognizes GitHub's fully-qualified owner/repo#N closing reference, repo-scoped", () => { const directRepo = repo("owner/direct"); const linkedIssuesFor = (body: string) => diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index a5d685d871..8231874e74 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -831,6 +831,23 @@ describe("v2 signal builders", () => { expect(missingPacket.findings.map((finding) => finding.code)).toContain("pr_not_cached"); }); + it("REGRESSION (#no-issue-rationale-exemption): buildPullRequestMaintainerPacket respects a clear no-issue rationale", () => { + const rationalePr = { ...pullRequests[0]!, linkedIssues: [], body: "No issue: docs typo fix." }; + const rationalePacket = buildPullRequestMaintainerPacket({ + repo, + pullRequest: rationalePr, + issues: [], + pullRequests: [rationalePr], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: rationalePr.number, + }); + expect(rationalePacket.findings.map((finding) => finding.code)).not.toContain("missing_linked_issue"); + }); + it("handles registry change report boundaries and all tracked fields", () => { const onlyCurrent = snapshot("only", [{ repo: "JSONbored/gittensory", emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: {} }]); const current = snapshot("current", [