diff --git a/packages/loopover-engine/src/config-lint.ts b/packages/loopover-engine/src/config-lint.ts index 2f45456c03..98efa5fec5 100644 --- a/packages/loopover-engine/src/config-lint.ts +++ b/packages/loopover-engine/src/config-lint.ts @@ -73,7 +73,7 @@ const MERGE_READINESS_SUB_GATES: ReadonlyArray<{ field: "linkedIssue" | "duplica ]; function mergeReadinessCompositeWarnings(gate: FocusManifestGateConfig): string[] { - if (gate.mergeReadiness === null) return []; + if (gate.mergeReadiness === null || gate.mergeReadiness === "off") return []; const explicit = MERGE_READINESS_SUB_GATES.filter(({ field }) => gate[field] !== null).map(({ label }) => label); if (explicit.length === 0) return []; return [ diff --git a/src/review/linked-issue-label-propagation-fetch.ts b/src/review/linked-issue-label-propagation-fetch.ts index e82b23e6b3..5bf0f9896e 100644 --- a/src/review/linked-issue-label-propagation-fetch.ts +++ b/src/review/linked-issue-label-propagation-fetch.ts @@ -279,8 +279,11 @@ async function resolveIssueLabelsForPropagation( // a fail-safe degrade, not a silent bypass: "no evidence this PR addresses the issue" must never be // conflated with "confirmed addressed." This check does NOT apply to a non-reward relaxed label (plain // `trustMaintainerAuthoredIssue`, e.g. bug/feature mirroring) or to the direct-ownership path above -- - // both keep their existing behavior unchanged. - const rewardCandidates = kept.filter((label) => rewardTrustLabels.has(label.toLowerCase())); + // both keep their existing behavior unchanged. `isDirectOwnershipMatch` excludes `relaxedRewardLabels` + // entirely from this check even though both land in `kept`: a reward label the direct-match branch + // already required maintainerAuthored for (#9161's own stricter fall-through) is genuine involvement, + // not a bare citation, so it never needs this additional evidence bar. + const rewardCandidates = isDirectOwnershipMatch ? [] : kept.filter((label) => rewardTrustLabels.has(label.toLowerCase())); if (rewardCandidates.length > 0) { const verdict = args.prNumber !== undefined diff --git a/test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-preserves-explicit-block.json b/test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-demotes-explicit-block.json similarity index 82% rename from test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-preserves-explicit-block.json rename to test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-demotes-explicit-block.json index e0fee87b09..02032f8b6a 100644 --- a/test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-preserves-explicit-block.json +++ b/test/fixtures/engine-parity/predicted-gate/golden/merge-readiness-composite-demotes-explicit-block.json @@ -2,11 +2,12 @@ "predicted": true, "basis": "public_config", "pack": "gittensor", - "conclusion": "failure", - "title": "LoopOver Orb Review Agent: No linked issue detected", - "summary": "No linked issue detected — If this PR is intended to solve an issue, link it explicitly in the PR body.", + "conclusion": "success", + "title": "LoopOver Orb Review Agent passed", + "summary": "No configured hard blocker was found. Advisory findings, if any, stay advisory.", "readinessScore": 80, - "blockers": [ + "blockers": [], + "warnings": [ { "code": "missing_linked_issue", "title": "No linked issue detected", @@ -14,7 +15,6 @@ "action": "If this PR is intended to solve an issue, link it explicitly in the PR body." } ], - "warnings": [], "funnel": null, "note": "Predicted from the repo's public .loopover.yml gate config + safe defaults. The maintainer may have private dashboard overrides not reflected here, and the dual-model AI-consensus blocker is only evaluated on a real PR. The slop score is NOT evaluated pre-submission (it needs the diff content) and may still fail the real gate. Provide the PR's changed paths to also predict the focus-manifest path policy, the size/guardrail hold, and any pre-merge check scoped to changed paths; without them only path-independent title/description/label pre-merge checks are predicted. Every author is gated the same: a configured hard blocker fails the gate regardless of confirmed-contributor status (which affects only on-chain scoring)." } diff --git a/test/fixtures/engine-parity/predicted-gate/index.ts b/test/fixtures/engine-parity/predicted-gate/index.ts index 2b35887048..ffddb239dc 100644 --- a/test/fixtures/engine-parity/predicted-gate/index.ts +++ b/test/fixtures/engine-parity/predicted-gate/index.ts @@ -4,7 +4,7 @@ import cleanPassGittensor from "./clean-pass-gittensor"; import cleanPassOssAntiSlop from "./clean-pass-oss-anti-slop"; import guardrailHold from "./guardrail-hold"; import mergeReadinessCompositeBlock from "./merge-readiness-composite-block"; -import mergeReadinessCompositePreservesExplicitBlock from "./merge-readiness-composite-preserves-explicit-block"; +import mergeReadinessCompositeDemotesExplicitBlock from "./merge-readiness-composite-demotes-explicit-block"; import selfAuthoredLinkedIssueBlock from "./self-authored-linked-issue-block"; import duplicatePrBlock from "./duplicate-pr-block"; import manifestBlockedPath from "./manifest-blocked-path"; @@ -27,7 +27,7 @@ export const predictedGateFixtures = [ selfAuthoredLinkedIssueBlock, guardrailHold, mergeReadinessCompositeBlock, - mergeReadinessCompositePreservesExplicitBlock, + mergeReadinessCompositeDemotesExplicitBlock, claGateModeInert, aiReviewGateModeInert, ]; diff --git a/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-demotes-explicit-block.ts b/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-demotes-explicit-block.ts new file mode 100644 index 0000000000..e4948ee610 --- /dev/null +++ b/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-demotes-explicit-block.ts @@ -0,0 +1,27 @@ +import { BASE_INPUT, BASE_REPO, definePredictedGateFixture, parseManifest } from "./_shared"; + +// #9167: the merge-readiness composite always OVERRIDES the sub-gates it covers (linkedIssue/duplicates/ +// slop) when set to advisory or block -- including an explicitly-authored stricter sub-gate mode. A +// "fill in only if left unset" variant was considered and reverted: every sub-gate mode is already a +// concrete, DB-defaulted value by the time it reaches GateCheckPolicy (never actually undefined there), so +// that variant could never fire in practice -- "explicitly authored" vs. "resolved to the shipped default" +// is indistinguishable at that layer. The demotion this fixture exercises is made VISIBLE instead, via +// config-lint.ts's mergeReadinessCompositeWarnings, which operates on the raw pre-default manifest where +// "unset" is a real, meaningful state. +export default definePredictedGateFixture({ + id: "merge-readiness-composite-demotes-explicit-block", + title: "Composite merge-readiness advisory mode demotes an explicitly-configured block sub-gate", + branch: "missing_linked_issue does not block when gate.mergeReadiness=advisory overrides an explicit gate.linkedIssue=block", + input: { ...BASE_INPUT, body: "No linked issue yet", linkedIssues: [] }, + manifest: parseManifest({ gate: { mergeReadiness: "advisory", linkedIssue: "block" } }), + repo: BASE_REPO, + issues: [], + pullRequests: [], + expected: { + conclusion: "success", + pack: "gittensor", + blockerCodes: [], + warningCodes: ["missing_linked_issue"], + funnelPresent: false, + }, +}); diff --git a/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-preserves-explicit-block.ts b/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-preserves-explicit-block.ts deleted file mode 100644 index 59e8a58036..0000000000 --- a/test/fixtures/engine-parity/predicted-gate/merge-readiness-composite-preserves-explicit-block.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BASE_INPUT, BASE_REPO, definePredictedGateFixture, parseManifest } from "./_shared"; - -// #9167 regression: mergeReadinessGateMode composite must not DEMOTE an explicitly-configured block -// sub-gate. Before #9167 this exact combination (mergeReadiness: advisory + linkedIssue: block) would -// have unconditionally overridden linkedIssueGateMode to "advisory", so a missing linked issue would -// never have blocked -- silently weaker than what the manifest visibly authored. Now the composite only -// fills in a sub-gate mode left unset; an explicit `block` always wins, so this still blocks. -export default definePredictedGateFixture({ - id: "merge-readiness-composite-preserves-explicit-block", - title: "Composite merge-readiness advisory mode does not demote an explicitly-configured block sub-gate", - branch: "missing_linked_issue still blocks when gate.mergeReadiness=advisory but gate.linkedIssue=block is explicit", - input: { ...BASE_INPUT, body: "No linked issue yet", linkedIssues: [] }, - manifest: parseManifest({ gate: { mergeReadiness: "advisory", linkedIssue: "block" } }), - repo: BASE_REPO, - issues: [], - pullRequests: [], - expected: { - conclusion: "failure", - pack: "gittensor", - blockerCodes: ["missing_linked_issue"], - warningCodes: [], - funnelPresent: false, - }, -}); diff --git a/test/unit/loopover-engine-scaffold.test.ts b/test/unit/loopover-engine-scaffold.test.ts index 692533833a..e260cc6be5 100644 --- a/test/unit/loopover-engine-scaffold.test.ts +++ b/test/unit/loopover-engine-scaffold.test.ts @@ -27,7 +27,7 @@ describe("loopover-engine package scaffold", () => { }); it("publishes only the build output and changelog, not source", () => { - expect(enginePkg.files).toEqual(["dist", "CHANGELOG.md"]); + expect(enginePkg.files).toEqual(["dist/**/*.js", "dist/**/*.d.ts", "CHANGELOG.md"]); expect(enginePkg.files).not.toContain("src"); }); diff --git a/test/unit/predicted-gate-engine.test.ts b/test/unit/predicted-gate-engine.test.ts index 137aa08265..d037bd7069 100644 --- a/test/unit/predicted-gate-engine.test.ts +++ b/test/unit/predicted-gate-engine.test.ts @@ -1555,7 +1555,10 @@ describe("predicted-gate engine branch coverage (#2283)", () => { expect(gateAdvisoryInternals.gateMode("off")).toBe("off"); expect(gateAdvisoryInternals.gateMode("block")).toBe("block"); expect(gateAdvisoryInternals.gateMode("advisory")).toBe("advisory"); - expect(gateAdvisoryInternals.gateMode(undefined)).toBe("advisory"); + // #9167: gateMode() fails CLOSED on a non-off/block/advisory value (including undefined) -- every + // legitimate caller supplies its own `?? "advisory"` default before reaching here, so this bare + // pass-through of `undefined` exercises the "truly malformed" fallback, not a normal call site. + expect(gateAdvisoryInternals.gateMode(undefined)).toBe("block"); expect(gateAdvisoryInternals.gatePolicyBlocks("advisory", "advisory")).toBe(false); expect(gateAdvisoryInternals.gatePolicyBlocks("block", "advisory")).toBe(true); expect(gateAdvisoryInternals.gatePolicyBlocks(undefined, "off")).toBe(false); diff --git a/test/unit/queue-5.test.ts b/test/unit/queue-5.test.ts index 49846cbcfb..2c2117eca5 100644 --- a/test/unit/queue-5.test.ts +++ b/test/unit/queue-5.test.ts @@ -6656,9 +6656,13 @@ describe("queue processors", () => { }, }); + // #9161: gittensor:priority is an ADDITIVE mapping here (removeOtherTypeLabels: false), so it is + // reward-semantic -- and the issue author here ("contributor") is the SAME login as the PR author, a + // direct-ownership match, who is NOT a repo maintainer. The reward label is correctly withheld + // (bug/feature-style type labels are unaffected); only gittensor:feature propagates. expect(seen.issueFetches).toBe(1); - expect(seen.posted.sort()).toEqual(["gittensor:feature", "gittensor:priority"]); - expect(seen.removed).toEqual(["gittensor:bug"]); + expect(seen.posted.sort()).toEqual(["gittensor:feature"]); + expect(seen.removed.sort()).toEqual(["gittensor:bug", "gittensor:priority"]); }); it("REGRESSION (#regression-safe-propagation, was: 'fails open to the normal title-based label'): skips the label decision entirely — never falls back to title — when the linked issue's fetch fails, leaving existing labels untouched", async () => { diff --git a/test/unit/reputation-wiring.test.ts b/test/unit/reputation-wiring.test.ts index 4036001471..c30b49c117 100644 --- a/test/unit/reputation-wiring.test.ts +++ b/test/unit/reputation-wiring.test.ts @@ -13,29 +13,45 @@ import { evaluateGateCheck } from "../../src/rules/advisory"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import type { Advisory, RepositorySettings } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; -import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; - -// Seeds one terminal review_targets row -- the raw table getSubmitterReputation(AcrossInstall) reads from -// (not part of the Drizzle schema; migrations/0050 is the source of truth for these columns). +import { upsertRepositoryFromGitHub, upsertPullRequestFromGitHub } from "../../src/db/repositories"; + +// #9136: seeds one terminal outcome via the LIVE ledger getSubmitterReputation(AcrossInstall)/ +// listSubmitterCohortRows now read (review_audit's pr_outcome + gate_decision rows, joined against +// pull_requests) -- review_targets was frozen by the 2026-06-22 convergence cutover and is no longer a +// live writer for this signal (see submitter-reputation.ts's own PR_OUTCOME_JOIN/REASON_CODE_SUBQUERY +// fragments, which this mirrors exactly). `status` becomes the pr_outcome row's `decision`; `reasonCode` +// becomes a paired gate_decision row's `summary`, read back via the latest-gate_decision-for-this-target +// subquery -- matching how a real close/merge webhook actually populates both rows in production. +let seedReviewTargetCounter = 0; async function seedReviewTarget( env: Env, args: { project: string; repo: string; number: number; installationId: number; submitter: string; status: string; reasonCode?: string | null }, ) { + await upsertPullRequestFromGitHub(env, args.repo, { + number: args.number, + title: "synthetic PR", + state: args.status === "merged" ? "closed" : "closed", + user: { login: args.submitter }, + head: { sha: `sha${args.number}` }, + labels: [], + merged_at: args.status === "merged" ? new Date().toISOString() : null, + } as unknown as Parameters[2]); + const targetId = `${args.project}#${args.number}`; + const uniq = `${Date.now()}-${(seedReviewTargetCounter += 1)}`; await env.DB.prepare( - `INSERT INTO review_targets (id, project, kind, repo, number, installation_id, submitter, status, decision_json, terminal_at) - VALUES (?, ?, 'pull_request', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + `INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, created_at) + VALUES (?, ?, ?, 'pr_outcome', ?, 'gittensory-native', NULL, NULL, CURRENT_TIMESTAMP)`, ) - .bind( - `${args.project}:pull_request:${args.repo}#${args.number}`, - args.project, - args.repo, - args.number, - args.installationId, - args.submitter, - args.status, - args.reasonCode === undefined ? null : JSON.stringify({ reasonCode: args.reasonCode }), - ) + .bind(`pr_outcome:${targetId}:${uniq}`, args.project, targetId, args.status) .run(); + if (args.reasonCode !== undefined && args.reasonCode !== null) { + await env.DB.prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, created_at) + VALUES (?, ?, ?, 'gate_decision', NULL, 'gittensory-native', NULL, ?, CURRENT_TIMESTAMP)`, + ) + .bind(`gate_decision:${targetId}:${uniq}`, args.project, targetId, args.reasonCode) + .run(); + } } // A submitter who FLOODED the project with submissions but landed almost none — the burst anti-abuse pattern. @@ -368,9 +384,10 @@ describe("ORB/AMS reputation bridge wiring (#6801)", () => { reasonCode: "dual_review_declined", }); } - // Keep the quality outcomes recent while making their submission cadence old enough not to trip the - // independent 24-hour machine-paced check after AMS upgrades the quality signal. - await env.DB.prepare("UPDATE review_targets SET created_at = '2026-01-01T00:00:00.000Z' WHERE submitter = ?").bind(submitter).run(); + // Keep the quality outcomes (review_audit) recent while making their submission cadence old enough not + // to trip the independent 24-hour machine-paced check after AMS upgrades the quality signal. #9015 + // repointed getSubmitterCadence to pull_requests.created_at (review_targets is no longer a live source). + await env.DB.prepare("UPDATE pull_requests SET created_at = '2026-01-01T00:00:00.000Z' WHERE author_login = ?").bind(submitter).run(); } it("REGRESSION (#6801): feature on upgrades a low local signal and changes the end-to-end gate decision", async () => { @@ -472,8 +489,11 @@ describe("getEffectiveSubmitterReputation (#4513, install-wide for a confirmed m await upsertRepositoryFromGitHub(env, { name: "repo-a", full_name: "org/repo-a", owner: { login: "org" } }, 999); // Only 1 sample on repo-a itself -- per-repo signal stays "neutral" (below minSample). await seedReviewTarget(env, { project: "org/repo-a", repo: "org/repo-a", number: 1, installationId: 999, submitter: "farmer99", status: "closed", reasonCode: "dual_review_declined" }); - // But spread across OTHER repos in the SAME install, farmer99 has a clear serial-decline pattern. + // But spread across OTHER repos in the SAME install, farmer99 has a clear serial-decline pattern. Each + // repo must be registered (getSubmitterReputationAcrossInstall joins review_audit against `repositories` + // by installation_id, #9136) for its seeded outcome to actually count toward the install-wide tally. for (let i = 0; i < 7; i++) { + await upsertRepositoryFromGitHub(env, { name: `repo-${i}`, full_name: `org/repo-${i}`, owner: { login: "org" } }, 999); await seedReviewTarget(env, { project: `org/repo-${i}`, repo: `org/repo-${i}`, number: i + 10, installationId: 999, submitter: "farmer99", status: "closed", reasonCode: "dual_review_declined" }); } vi.stubGlobal("fetch", stubMinerFetch("farmer99"));