diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx index 86b5d47bf0..46bac21749 100644 --- a/apps/gittensory-ui/src/routes/docs.tuning.tsx +++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx @@ -157,9 +157,11 @@ function Tuning() { GITTENSORY_REVIEW_MEMORY — repeat-false-positive suppression: matches an advisory (non-blocking) AI finding against this repo's stored suppression signals (a maintainer's own past false-positive dismissals) and demotes or drops it before the - unified comment renders. Advisory-only by construction — never applied to gate blockers, - so it can never change the merge/close disposition. Also requires the per-repo{" "} - review.memory: true opt-in in .gittensory.yml. Per-PR. + unified comment renders. A maintainer records a signal with{" "} + @gittensory resolve [finding-code] (or a whole-PR{" "} + @gittensory resolve ack). Advisory-only by construction — never applied to + gate blockers, so it can never change the merge/close disposition. Also requires the + per-repo review.memory: true opt-in in .gittensory.yml. Per-PR.
  • GITTENSORY_REVIEW_REPUTATION — submitter-reputation spend control. A new, diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 312a7888e9..8c3bfba0c8 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5504,6 +5504,7 @@ async function processGitHubWebhook( return; } + if (eventName === "issue_comment" && (await maybeProcessResolveCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; } if ( eventName === "issue_comment" && (await maybeProcessPlanCommand(env, deliveryId, payload)) @@ -10342,6 +10343,35 @@ async function recordGateOverrideSkip( }); } +async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { const command = parseGittensoryMentionCommand(payload.comment?.body); + if (!command) return false; + if (command.name !== "resolve") return false; + const { classifyPrCommandRequest } = await import("../github/pr-command-request"); + const { normalizeResolveFindingRef, selectWarningsForResolve } = await import("../review/review-memory-wire"); + const req = classifyPrCommandRequest(payload, getInstallationId(payload)); + if (!req.ok) { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey: req.targetKey, outcome: "completed", detail: req.reason, metadata: { deliveryId, repoFullName: req.repoFullName ?? null, reason: req.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey: req.targetKey, outcome: "skipped", metadata: { reason: req.reason } }); return true; } + const [pr, settings] = await Promise.all([getPullRequest(env, req.repoFullName, req.pr.number), resolveRepositorySettings(env, req.repoFullName)]); + const targetKey = `${req.repoFullName}#${req.pr.number}`; + if (!pr) { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: "cached_pr_missing", metadata: { deliveryId, repoFullName: req.repoFullName, reason: "cached_pr_missing" } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: "cached_pr_missing" } }); return true; } + const { authorization } = await authorizePrActionActor({ env, deliveryId, installationId: req.installationId, repoFullName: req.repoFullName, issue: payload.issue!, actor: req.actor, commandName: "resolve" as GittensoryMentionCommandName, settings, pr }); + if (!authorization.authorized) { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_denied", actor: req.actor, targetKey, outcome: "denied", detail: authorization.reason, metadata: { deliveryId, repoFullName: req.repoFullName, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "resolve") } }); await recordGithubProductUsage(env, "finding_resolved_denied", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "denied", metadata: { reason: authorization.reason, actorKind: authorization.actorKind, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "resolve") } }); return true; } + const findingRef = normalizeResolveFindingRef(command.reason); + if (!findingRef.ok) { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: findingRef.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: findingRef.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: findingRef.reason } }); return true; } + const { advisory } = await buildAuthorizedPrActionAdvisory(env, req.repoFullName, pr, settings); + const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null)); + const selection = selectWarningsForResolve(gate.warnings, findingRef); + if (selection.reason === "finding_not_found") { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: selection.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: selection.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: selection.reason } }); return true; } + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: skipReason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: skipReason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: skipReason } }); return true; } + const reviewManifest = await loadRepoFocusManifest(env, req.repoFullName).catch(() => null); + const reviewMemoryEnabled = shouldApplyReviewMemory(env, resolveReviewMemoryManifestToggle(reviewManifest)); + let recordedSuppressionCount = 0; + if (reviewMemoryEnabled && selection.findings.length > 0) { const { fingerprint } = await import("../review/review-memory-match"); const { recordReviewSuppression } = await import("../db/repositories"); const suppressionWrites = selection.findings.map((finding) => ({ category: finding.code, pathGlob: "", patternHash: fingerprint({ category: finding.code, message: `${finding.title} ${finding.detail}` }) })); await Promise.all(suppressionWrites.map((write) => recordReviewSuppression(env, { repoFullName: req.repoFullName, category: write.category, pathGlob: write.pathGlob, patternHash: write.patternHash, createdBy: req.actor }))); recordedSuppressionCount = suppressionWrites.length; await recordAuditEvent(env, { eventType: "github_app.review_memory_recorded", actor: req.actor, targetKey, outcome: "completed", detail: `Recorded ${recordedSuppressionCount} review-memory suppression signal(s).`, metadata: { deliveryId, repoFullName: req.repoFullName, recordedSuppressionCount, scope: findingRef.scope, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); await recordGithubProductUsage(env, "review_memory_recorded", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { recordedSuppressionCount, scope: findingRef.scope, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); } + const resolvedLabel = findingRef.scope === "whole_pr" ? "all current advisory findings" : `\`${findingRef.findingCode}\``; + const confirmation = sanitizePublicComment([AGENT_COMMAND_COMMENT_MARKER, "", "> [!NOTE]", `> **Review finding resolved by @${req.actor}**`, `> Marked ${resolvedLabel} as resolved for this PR. The Gate check-run is unchanged.`, ...(recordedSuppressionCount > 0 ? ["", `Recorded ${recordedSuppressionCount} review-memory suppression signal(s) for future reviews.`] : []), "", "---", gittensoryFooter()].join("\n")); + await createOrUpdateAgentCommandComment(env, req.installationId, req.repoFullName, req.pr.number, confirmation, mode); + await recordAuditEvent(env, { eventType: "github_app.finding_resolved", actor: req.actor, targetKey, outcome: "completed", detail: `Marked ${resolvedLabel} as resolved.`, metadata: { deliveryId, repoFullName: req.repoFullName, scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); + await recordGithubProductUsage(env, "finding_resolved", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); return true; } /** * `@gittensory plan` (#issue-coding-plan, flag-gated by GITTENSORY_REVIEW_PLANNER). On a MAINTAINER's comment on * an ISSUE (not a PR), generate a concise implementation plan from the issue text via Workers AI and post it as an diff --git a/src/review/review-memory-wire.ts b/src/review/review-memory-wire.ts index 2db0b0cd8d..ea64b67f5a 100644 --- a/src/review/review-memory-wire.ts +++ b/src/review/review-memory-wire.ts @@ -26,6 +26,9 @@ export function shouldApplyReviewMemory( ): boolean { return isReviewMemoryEnabled(env) && manifestReviewMemoryEnabled; } +const RESOLVE_FINDING_CODE = /^[a-z][a-z0-9_]{0,199}$/; +export function normalizeResolveFindingRef(raw: string | null | undefined): { ok: true; scope: "whole_pr" } | { ok: true; scope: "single"; findingCode: string } | { ok: false; reason: "malformed_finding_id" } { const trimmed = (raw ?? "").trim(); if (trimmed.length === 0) return { ok: true, scope: "whole_pr" }; const normalized = trimmed.toLowerCase().replace(/^finding-/, ""); if (!RESOLVE_FINDING_CODE.test(normalized)) return { ok: false, reason: "malformed_finding_id" }; return { ok: true, scope: "single", findingCode: normalized }; } +export function selectWarningsForResolve(warnings: ReadonlyArray, ref: { ok: true; scope: "whole_pr" } | { ok: true; scope: "single"; findingCode: string }): { findings: AdvisoryFinding[]; reason?: "finding_not_found" } { if (ref.scope === "whole_pr") return { findings: [...warnings] }; const matches = warnings.filter((finding) => finding.code === ref.findingCode); if (matches.length === 0) return { findings: [], reason: "finding_not_found" }; return { findings: matches }; } /** Apply-to-findings wiring (#2181, apply slice of #1964). PURE — no DB I/O (the caller already resolved * `signals` via listReviewSuppressions); the caller wraps the READ side in its own try/catch (fail-safe: a diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 153f5aaf0b..9afa4b7d92 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -52,6 +52,7 @@ import { putCachedAiReview, markAiReviewPublished, recordReviewSuppression, + listReviewSuppressions, } from "../../src/db/repositories"; import { agentMaintenanceHeadMatchesGate, changedPathsForGuardrail, claimAiReviewLock, claimPrActuationLock, contributorEvidenceBatchSize, enrichOpenPullRequestsWithChangedFiles, processJob, reconcileLiveDuplicateSiblings, releaseAiReviewLock, releasePrActuationLock, SWEEP_FANOUT_RESOLUTION_CONCURRENCY } from "../../src/queue/processors"; import type { PullRequestRecord } from "../../src/types"; @@ -22167,6 +22168,429 @@ describe("queue processors", () => { expect(overridden ?? null).toBeNull(); }); + // #1964 (record slice): `@gittensory resolve` records review-memory suppression signals for advisory warnings. + describe("@gittensory resolve (#1964)", () => { + async function seedResolvePr(env: Env, repoFullName: string, prNumber: number, headSha: string) { + const slash = repoFullName.indexOf("/"); + const owner = slash >= 0 ? repoFullName.slice(0, slash) : repoFullName; + const name = slash >= 0 ? repoFullName.slice(slash + 1) : repoFullName; + await upsertRepositoryFromGitHub(env, { name, full_name: repoFullName, private: false, owner: { login: owner } }, 123); + await upsertRepositorySettings(env, { + repoFullName, + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + requireLinkedIssue: true, + linkedIssueGateMode: "advisory", + }); + await upsertPullRequestFromGitHub(env, repoFullName, { + number: prNumber, + title: "Resolve me", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: headSha }, + labels: [], + body: "No linked issue on purpose", + }); + } + + it("records a suppression signal and finding_resolved when an authorized maintainer resolves a named warning with review.memory ON", async () => { + const repoFullName = "JSONbored/resolve-1964-a"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + GITTENSORY_REVIEW_MEMORY: "true", + }); + await seedResolvePr(env, repoFullName, 1964, "resolve-1964-a"); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + const calls = { permission: 0, checkPatches: 0, comments: 0 }; + let confirmationBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) { + calls.permission += 1; + return Response.json({ permission: "admin" }); + } + if (url.includes("/issues/1964/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/1964/comments") && method === "POST") { + calls.comments += 1; + confirmationBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + return Response.json({ id: 19641 }); + } + if (url.includes("/check-runs") && method === "PATCH") { + calls.checkPatches += 1; + return Response.json({ id: 1 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1964-allow", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1964-a", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1964, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { + id: 19640, + body: "@gittensory resolve missing_linked_issue", + author_association: "NONE", + user: { login: "maintainer", type: "User" }, + }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + expect(calls.permission).toBe(1); + expect(calls.checkPatches).toBe(0); + expect(calls.comments).toBe(1); + expect(confirmationBody).toContain("Review finding resolved"); + expect(confirmationBody).toContain("missing_linked_issue"); + expect(confirmationBody).toContain("Gate check-run is unchanged"); + const resolved = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?") + .bind("github_app.finding_resolved") + .first<{ outcome: string; detail: string }>(); + expect(resolved).toMatchObject({ outcome: "completed" }); + const memoryRecorded = await env.DB.prepare("select outcome from audit_events where event_type = ?") + .bind("github_app.review_memory_recorded") + .first<{ outcome: string }>(); + expect(memoryRecorded).toMatchObject({ outcome: "completed" }); + const suppressions = await listReviewSuppressions(env, repoFullName); + expect(suppressions).toHaveLength(1); + expect(suppressions[0]).toMatchObject({ + category: "missing_linked_issue", + createdBy: "maintainer", + }); + }); + + it("records finding_resolved without a suppression write when review.memory is OFF (operator kill-switch)", async () => { + const repoFullName = "JSONbored/resolve-1965-off"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedResolvePr(env, repoFullName, 1965, "resolve-1965-off"); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/1965/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/1965/comments") && method === "POST") return Response.json({ id: 19651 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1965-flag-off", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1965-off", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1965, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { + id: 19650, + body: "@gittensory resolve missing_linked_issue", + author_association: "NONE", + user: { login: "maintainer", type: "User" }, + }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + const memoryRecorded = await env.DB.prepare("select id from audit_events where event_type = ?") + .bind("github_app.review_memory_recorded") + .first<{ id: string }>(); + expect(memoryRecorded ?? null).toBeNull(); + expect(await listReviewSuppressions(env, repoFullName)).toHaveLength(0); + const resolved = await env.DB.prepare("select outcome from audit_events where event_type = ?") + .bind("github_app.finding_resolved") + .first<{ outcome: string }>(); + expect(resolved).toMatchObject({ outcome: "completed" }); + }); + + it("denies an unauthorized actor and records no suppression signal", async () => { + const repoFullName = "JSONbored/resolve-1966-deny"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + await seedResolvePr(env, repoFullName, 1966, "resolve-1966-deny"); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/org-member/permission")) return Response.json({ permission: "read" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1966-deny", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1966-deny", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1966, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { + id: 19660, + body: "@gittensory resolve missing_linked_issue", + author_association: "MEMBER", + user: { login: "org-member", type: "User" }, + }, + sender: { login: "org-member", type: "User" }, + }, + }); + + const denied = await env.DB.prepare("select outcome from audit_events where event_type = ?") + .bind("github_app.finding_resolved_denied") + .first<{ outcome: string }>(); + expect(denied).toMatchObject({ outcome: "denied" }); + expect(await listReviewSuppressions(env, repoFullName)).toHaveLength(0); + }); + + it.each([ + ["malformed finding id", "@gittensory resolve ../escape", "malformed_finding_id"], + ["absent finding code", "@gittensory resolve readiness_score_below_threshold", "finding_not_found"], + ] as const)("skips resolve when the maintainer supplies %s", async (_label, body, reason) => { + const repoFullName = "JSONbored/resolve-1967-skip"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + await seedResolvePr(env, repoFullName, 1967, "resolve-1967-skip"); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: `resolve-1967-${reason}`, + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1967-skip", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1967, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19670, body, author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?") + .bind("github_app.finding_resolved_skipped") + .first<{ detail: string }>(); + expect(skipped?.detail).toBe(reason); + expect(await listReviewSuppressions(env, repoFullName)).toHaveLength(0); + }); + + it("records every current advisory warning for a whole-PR `@gittensory resolve` ack", async () => { + const repoFullName = "JSONbored/resolve-1968-whole"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + await seedResolvePr(env, repoFullName, 1968, "resolve-1968-whole"); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/1968/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/1968/comments") && method === "POST") return Response.json({ id: 19681 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1968-whole", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1968-whole", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1968, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19680, body: "@gittensory resolve", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + expect(await listReviewSuppressions(env, repoFullName)).toHaveLength(2); + const resolved = await env.DB.prepare("select metadata_json from audit_events where event_type = ?") + .bind("github_app.finding_resolved") + .first<{ metadata_json: string }>(); + expect(JSON.parse(resolved?.metadata_json ?? "{}")).toMatchObject({ scope: "whole_pr", resolvedWarningCount: 2 }); + }); + + it("ignores issue comments that are not @gittensory resolve commands (#1964)", async () => { + const repoFullName = "JSONbored/resolve-1973-plain"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedResolvePr(env, repoFullName, 1973, "resolve-1973-plain"); + let commentPosts = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/1973/comments") && method === "POST") { + commentPosts += 1; + return Response.json({ id: 19730 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-plain-comment", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1973-plain", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1973, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19731, body: "Looks good to me", author_association: "OWNER", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + expect(commentPosts).toBe(0); + const events = await env.DB.prepare("select event_type from audit_events where event_type like ?").bind("github_app.finding_resolved%").all<{ event_type: string }>(); + expect(events.results ?? []).toEqual([]); + }); + + it("ignores other @gittensory verbs on the resolve handler path (#1964)", async () => { + const repoFullName = "JSONbored/resolve-1974-help"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedResolvePr(env, repoFullName, 1974, "resolve-1974-help"); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-help-verb", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1974-help", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1974, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19740, body: "@gittensory help", author_association: "OWNER", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + const events = await env.DB.prepare("select event_type from audit_events where event_type like ?").bind("github_app.finding_resolved%").all<{ event_type: string }>(); + expect(events.results ?? []).toEqual([]); + }); + + it("skips resolve when the webhook payload lacks a repository (#1964)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-missing-repo", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + issue: { number: 1972, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19720, body: "@gittensory resolve missing_linked_issue", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.finding_resolved_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("missing_repo_pr_installation_or_actor"); + }); + + it("skips resolve when the cached pull request row is missing (#1964)", async () => { + const repoFullName = "JSONbored/resolve-1969-missing-pr"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + const slash = repoFullName.indexOf("/"); + await upsertRepositoryFromGitHub(env, { name: repoFullName.slice(slash + 1), full_name: repoFullName, private: false, owner: { login: repoFullName.slice(0, slash) } }, 123); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1969-missing-pr", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1969-missing-pr", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1969, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19690, body: "@gittensory resolve missing_linked_issue", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.finding_resolved_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("cached_pr_missing"); + }); + + it("skips resolve in agentDryRun without recording finding_resolved (#1964)", async () => { + const repoFullName = "JSONbored/resolve-1970-dry-run"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + await seedResolvePr(env, repoFullName, 1970, "resolve-1970-dry-run"); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: true, linkedIssueGateMode: "advisory", agentDryRun: true }); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1970-dry-run", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1970-dry-run", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1970, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19700, body: "@gittensory resolve missing_linked_issue", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.finding_resolved_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("dry_run"); + const resolved = await env.DB.prepare("select id from audit_events where event_type = ?").bind("github_app.finding_resolved").first<{ id: string }>(); + expect(resolved ?? null).toBeNull(); + }); + + it("skips resolve when the repository is agentPaused (#1964)", async () => { + const repoFullName = "JSONbored/resolve-1971-paused"; + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_MEMORY: "true" }); + await seedResolvePr(env, repoFullName, 1971, "resolve-1971-paused"); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: true, linkedIssueGateMode: "advisory", agentPaused: true }); + await upsertRepoFocusManifest(env, repoFullName, { review: { memory: true } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "resolve-1971-paused", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "resolve-1971-paused", full_name: repoFullName, private: false, owner: { login: "JSONbored" } }, + issue: { number: 1971, title: "Resolve me", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 19710, body: "@gittensory resolve missing_linked_issue", author_association: "NONE", user: { login: "maintainer", type: "User" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.finding_resolved_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("agent_paused"); + }); + }); + it("a #1960 action-command verb with no dispatch handler wired yet (e.g. pause) is bailed out of the Q&A answer-card path, not misrendered as help (#2160)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); diff --git a/test/unit/review-memory-record.test.ts b/test/unit/review-memory-record.test.ts new file mode 100644 index 0000000000..db5cb85755 --- /dev/null +++ b/test/unit/review-memory-record.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { normalizeResolveFindingRef, selectWarningsForResolve } from "../../src/review/review-memory-wire"; +import type { AdvisoryFinding } from "../../src/types"; + +function warning(overrides: Partial = {}): AdvisoryFinding { + return { + code: "readiness_score_below_threshold", + severity: "warning", + title: "Readiness score is below the configured threshold", + detail: "The public readiness score is 25/100, below the repository threshold of 100/100.", + ...overrides, + }; +} + +describe("normalizeResolveFindingRef (#1964)", () => { + it("treats empty/absent trailing text as a whole-PR ack", () => { + expect(normalizeResolveFindingRef(undefined)).toEqual({ ok: true, scope: "whole_pr" }); + expect(normalizeResolveFindingRef("")).toEqual({ ok: true, scope: "whole_pr" }); + expect(normalizeResolveFindingRef(" ")).toEqual({ ok: true, scope: "whole_pr" }); + }); + + it("accepts a bare finding code and the optional finding- prefix", () => { + expect(normalizeResolveFindingRef("readiness_score_below_threshold")).toEqual({ + ok: true, + scope: "single", + findingCode: "readiness_score_below_threshold", + }); + expect(normalizeResolveFindingRef("finding-readiness_score_below_threshold")).toEqual({ + ok: true, + scope: "single", + findingCode: "readiness_score_below_threshold", + }); + }); + + it("rejects malformed finding references", () => { + expect(normalizeResolveFindingRef("../escape")).toEqual({ ok: false, reason: "malformed_finding_id" }); + expect(normalizeResolveFindingRef("Bad-Hyphen")).toEqual({ ok: false, reason: "malformed_finding_id" }); + expect(normalizeResolveFindingRef("has space")).toEqual({ ok: false, reason: "malformed_finding_id" }); + expect(normalizeResolveFindingRef("9starts_with_digit")).toEqual({ ok: false, reason: "malformed_finding_id" }); + }); +}); + +describe("selectWarningsForResolve (#1964)", () => { + it("returns every warning for a whole-PR ack", () => { + const warnings = [warning(), warning({ code: "duplicate_pr_risk", title: "Duplicate risk" })]; + expect(selectWarningsForResolve(warnings, { ok: true, scope: "whole_pr" })).toEqual({ findings: warnings }); + }); + + it("returns only the matching warning for a single-finding ref", () => { + const a = warning(); + const b = warning({ code: "duplicate_pr_risk", title: "Duplicate risk" }); + expect(selectWarningsForResolve([a, b], { ok: true, scope: "single", findingCode: "duplicate_pr_risk" })).toEqual({ + findings: [b], + }); + }); + + it("reports finding_not_found when the requested code is absent from the warnings", () => { + expect( + selectWarningsForResolve([warning()], { ok: true, scope: "single", findingCode: "missing_linked_issue" }), + ).toEqual({ findings: [], reason: "finding_not_found" }); + }); +}); diff --git a/test/unit/sweep-watchdog.test.ts b/test/unit/sweep-watchdog.test.ts index 1df3827963..c41440ccf6 100644 --- a/test/unit/sweep-watchdog.test.ts +++ b/test/unit/sweep-watchdog.test.ts @@ -62,7 +62,7 @@ describe("runSweepLivenessWatchdog (#audit-sweep-fanout-isolation follow-up)", ( }); it("REGRESSION: reports a finite ageMs for a repo that WAS regated once but fell outside the staleness window (not just a never-regated null marker)", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ shouldAdvanceTime: true }); const start = new Date("2026-07-06T10:00:00.000Z"); vi.setSystemTime(start); const sent: import("../../src/types").JobMessage[] = []; @@ -78,7 +78,7 @@ describe("runSweepLivenessWatchdog (#audit-sweep-fanout-isolation follow-up)", ( expect(found).toEqual([expect.objectContaining({ repoFullName: "owner/aged-repo", lastRegatedAt: start.toISOString(), ageMs: SWEEP_STALENESS_THRESHOLD_MS + 60_000 })]); expect(Number.isFinite(found[0]?.ageMs)).toBe(true); expect(sent).toEqual([expect.objectContaining({ type: "agent-regate-sweep", repoFullName: "owner/aged-repo" })]); - }); + }, 60_000); it("watches an ALLOWLISTED (GITTENSORY_REVIEW_REPOS) installed repo even with no autonomy configured, and skips a plain repo that is neither allowlisted nor agent-configured", async () => { const sent: import("../../src/types").JobMessage[] = [];