diff --git a/.loopover.yml.example b/.loopover.yml.example index 2e48ec9367..268544289c 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -870,6 +870,13 @@ settings: # inherit | off | enabled. Default: inherit. # openPrFileCollisionMode: inherit + # `@loopover plan` issue command (#issue-coding-plan): on a maintainer's request, generates a concise + # implementation plan from an issue's text and posts it as a comment. "inherit" defers to the self-host + # operator's LOOPOVER_REVIEW_PLANNER env default (itself off by default); "off"/"enabled" override that + # default in either direction for this repo. + # inherit | off | enabled. Default: inherit. + # plannerMode: inherit + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 5a84eb566c..06aa0ffd19 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9681,6 +9681,14 @@ "off", "enabled" ] + }, + "plannerMode": { + "type": "string", + "enum": [ + "inherit", + "off", + "enabled" + ] } }, "required": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 8f6763555f..9f8f1e6c89 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -884,6 +884,13 @@ settings: # inherit | off | enabled. Default: inherit. # openPrFileCollisionMode: inherit + # `@loopover plan` issue command (#issue-coding-plan): on a maintainer's request, generates a concise + # implementation plan from an issue's text and posts it as a comment. "inherit" defers to the self-host + # operator's LOOPOVER_REVIEW_PLANNER env default (itself off by default); "off"/"enabled" override that + # default in either direction for this repo. + # inherit | off | enabled. Default: inherit. + # plannerMode: inherit + # Hard manual-review path guardrails are config-as-code only. Safe by default (#3943): whatever you list # here is ADDED to a fixed, built-in invariant set (CI workflows/scripts, deploy config, config-as-code # files, core engine-decision paths — see DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index e0bdb12594..992ac23ded 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -406,6 +406,7 @@ export type FocusManifestSettings = Partial< | "skipAutomationBotAuthors" | "duplicateWinnerMode" | "openPrFileCollisionMode" + | "plannerMode" | "autoLabelEnabled" | "typeLabelsEnabled" | "badgeEnabled" @@ -2268,6 +2269,10 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) // default -- "inherit" defers to it, "off"/"enabled" override in either direction for this repo. const openPrFileCollisionMode = normalizeOptionalEnum(r.openPrFileCollisionMode, "settings.openPrFileCollisionMode", ["inherit", "off", "enabled"] as const, warnings); if (openPrFileCollisionMode !== null) out.openPrFileCollisionMode = openPrFileCollisionMode; + // Issue-planning command (#issue-coding-plan): per-repo override of the global LOOPOVER_REVIEW_PLANNER + // default -- "inherit" defers to it, "off"/"enabled" override in either direction for this repo. + const plannerMode = normalizeOptionalEnum(r.plannerMode, "settings.plannerMode", ["inherit", "off", "enabled"] as const, warnings); + if (plannerMode !== null) out.plannerMode = plannerMode; // Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config. const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"] as const, warnings); if (moderationGateMode !== null) out.moderationGateMode = moderationGateMode; diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index 3e56c34571..27cc08aefa 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -360,6 +360,11 @@ export type RepositorySettings = { * override the global default in either direction for this repo. No DB column -- config-as-code only, set * via `.loopover.yml settings.openPrFileCollisionMode`. */ openPrFileCollisionMode?: "inherit" | "off" | "enabled" | undefined; + /** Issue-planning command (#issue-coding-plan): per-repo override of the `@loopover plan` issue command. + * `"inherit"` defers to the `LOOPOVER_REVIEW_PLANNER` global env default (itself default-OFF); `"off"`/ + * `"enabled"` fully override the global default in either direction for this repo. No DB column -- + * config-as-code only, set via `.loopover.yml settings.plannerMode`. */ + plannerMode?: "inherit" | "off" | "enabled" | undefined; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 6b8bed917f..219bf5e1e4 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -849,6 +849,7 @@ export const RepositorySettingsSchema = z skipAutomationBotAuthors: z.enum(["inherit", "off", "enabled"]).optional(), duplicateWinnerMode: z.enum(["inherit", "off", "enabled"]).optional(), openPrFileCollisionMode: z.enum(["inherit", "off", "enabled"]).optional(), + plannerMode: z.enum(["inherit", "off", "enabled"]).optional(), reviewEvasionProtection: z .enum(["off", "close"]) .optional() diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9a3acada9e..63d516fff9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -538,6 +538,7 @@ import { isPlanCommand, isPlannerEnabled, } from "../review/planner"; +import { resolvePlannerEnabled } from "../settings/planner-mode"; import { classifyConfigurationCommandRequest } from "../github/configuration-command"; import { summarizeEffectiveConfig } from "../settings/effective-config-summary"; import { makeGithubFileFetcher } from "../review/grounding-wire"; @@ -11364,25 +11365,39 @@ async function recordConfigurationSkip( } /** - * `@loopover plan` (#issue-coding-plan, flag-gated by LOOPOVER_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 - * issue comment so a contributor has a concrete starting point. Flag-OFF (default) returns false immediately - * (BEFORE any parse), so `@loopover plan` falls through to the existing mention path → byte-identical. Returns - * true once it owns the event (so the caller records it processed and stops). Fail-safe: a model/post error is - * recorded as a skip and never throws into the webhook loop. A per-actor/per-repo cooldown prevents repeated - * maintainer comments from spending shared AI quota in a burst. + * `@loopover plan` (#issue-coding-plan, gated by LOOPOVER_REVIEW_PLANNER + the per-repo `settings.plannerMode` + * override, #issue-coding-plan-config). 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 issue comment so a contributor has a + * concrete starting point. Disabled (fleet default OFF and no repo override) returns false before any classify/ + * parse, so `@loopover plan` falls through to the existing mention path → byte-identical. Returns true once it + * owns the event (so the caller records it processed and stops). Fail-safe: a model/post error is recorded as a + * skip and never throws into the webhook loop. A per-actor/per-repo cooldown prevents repeated maintainer comments + * from spending shared AI quota in a burst. */ async function maybeProcessPlanCommand( env: Env, deliveryId: string, payload: GitHubWebhookPayload, ): Promise { - if (!isPlannerEnabled(env)) return false; // flag-OFF → not handled here; the worker is byte-identical to today + // Cheap synchronous checks FIRST: an unrelated comment or a PR-thread comment never pays for a manifest load + // below, matching the pre-#issue-coding-plan-config behavior byte-for-byte for both cases. if (!isPlanCommand(payload.comment?.body)) return false; // #22: planning is ISSUE-only. A `@loopover plan` on a PR is not a plan request, so DON'T consume it — fall - // through to the generic mention handler so it posts the help card, exactly as the flag-OFF path does. Without - // this the flag-ON worker swallowed a PR-thread `plan` mention and the contributor saw nothing. + // through to the generic mention handler so it posts the help card, exactly as the disabled path does. Without + // this the enabled worker swallowed a PR-thread `plan` mention and the contributor saw nothing. if (payload.issue?.pull_request) return false; + // Per-repo override (#issue-coding-plan-config): `.loopover.yml settings.plannerMode` can turn the command ON + // for a repo even when the fleet default (LOOPOVER_REVIEW_PLANNER) is off, or OFF even when the fleet default + // is on — mirrors resolveDuplicateWinnerEnabled's inherit/off/enabled shape. repoFullName is read directly off + // the payload (the same one-liner classifyPlanCommandRequest below uses) rather than running the classifier + // just to get it, since the classifier also needs installationId/actor this cheap gate doesn't. No repo name + // at all (edge case) ⇒ no manifest to load, so the fleet-only default decides. loadRepoFocusManifest is + // fail-safe on its own, but a webhook handler must never let a manifest-load blip throw into the queue loop — + // same `.catch(() => null)` convention every other manifest-driven feature in this file uses (e.g. + // runReviewRecapJob, resolveVisualCaptureConfig). + const repoFullName = payload.repository?.full_name ?? null; + const manifest = repoFullName ? await loadRepoFocusManifest(env, repoFullName).catch(() => null) : null; + if (!resolvePlannerEnabled(isPlannerEnabled(env), manifest?.settings.plannerMode)) return false; // All eligibility guards live in the PURE classifier (exhaustively unit-tested); here we carry one ok branch. const req = classifyPlanCommandRequest(payload, getInstallationId(payload)); if (!req.ok) { diff --git a/src/review/planner.ts b/src/review/planner.ts index 977cb684bd..89d161f795 100644 --- a/src/review/planner.ts +++ b/src/review/planner.ts @@ -3,9 +3,11 @@ // (or their agent) has a concrete starting point. // // SAFETY CONTRACT: -// • flag-OFF (default) → isPlannerEnabled is false, the handler short-circuits BEFORE parsing, and the worker -// is byte-identical to today (`@loopover plan` falls through to the existing mention path → help card). -// • flag-ON → only a MAINTAINER can trigger it; the model sees only the (already-public) issue title + body; +// • disabled (fleet default OFF, and no repo `settings.plannerMode: enabled` override, #issue-coding-plan-config) +// → the handler (maybeProcessPlanCommand, queue/processors.ts) short-circuits BEFORE classifying the request, +// and the worker is byte-identical to today (`@loopover plan` falls through to the existing mention path → +// help card). +// • enabled → only a MAINTAINER can trigger it; the model sees only the (already-public) issue title + body; // shared AI budget accounting runs before the configured reviewer (self-host Codex/Claude Code/etc, or the // legacy Workers-AI pair); the output is public-safe-sanitized before posting; any model/error degrades to // a no-plan no-op. @@ -17,8 +19,11 @@ import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments"; import { loopoverFooter, type LoopOverFooterEnv } from "../github/footer"; import type { GitHubWebhookPayload } from "../types"; -/** True when the issue-planning command is enabled. Flag-OFF (default) → every export below is unreachable from - * the webhook path. Truthy follows the codebase convention (`/^(1|true|yes|on)$/i`, same as isSelfTuneEnabled). */ +/** True when the issue-planning command is enabled FLEET-WIDE (the global default). A repo can still override + * this in either direction via `.loopover.yml settings.plannerMode` -- see `resolvePlannerEnabled` + * (settings/planner-mode.ts), the per-repo resolver half of this pair (mirrors + * isDuplicateWinnerEnabledGlobally/resolveDuplicateWinnerEnabled's split, settings/duplicate-winner-mode.ts). + * Truthy follows the codebase convention (`/^(1|true|yes|on)$/i`, same as isSelfTuneEnabled). */ export function isPlannerEnabled(env: { LOOPOVER_REVIEW_PLANNER?: string | undefined; }): boolean { diff --git a/src/settings/planner-mode.ts b/src/settings/planner-mode.ts new file mode 100644 index 0000000000..66b08e38a0 --- /dev/null +++ b/src/settings/planner-mode.ts @@ -0,0 +1,16 @@ +export type PlannerMode = "inherit" | "off" | "enabled"; + +/** Per-repo override resolved against the global default. Mirrors `resolveDuplicateWinnerEnabled`'s + * inherit/off/enabled shape (settings/duplicate-winner-mode.ts) -- symmetric: "off" and "enabled" both fully + * override the global default in either direction, so a repo that wants `@loopover plan` isn't blocked by a + * globally-off `LOOPOVER_REVIEW_PLANNER` default, and a repo that wants to opt OUT keeps the command disabled + * even when the fleet default is on. The global-default read itself stays `isPlannerEnabled` in + * `review/planner.ts` (predates this file, already the correct env-var-default shape, and every other + * `isPlannerEnabled` call site keeps importing it from there) -- this file adds only the missing per-repo + * resolver half of the settings/*-mode.ts pair, matching duplicate-winner-mode.ts/open-pr-file-collision-mode.ts's + * file-organization convention. */ +export function resolvePlannerEnabled(globalDefault: boolean, mode: PlannerMode | null | undefined): boolean { + if (mode === "off") return false; + if (mode === "enabled") return true; + return globalDefault; +} diff --git a/src/types.ts b/src/types.ts index a24f480f6c..4bce741f31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1233,6 +1233,13 @@ export type RepositorySettings = { * (itself default-OFF); `"off"`/`"enabled"` fully override the global default in either direction for this * repo. No DB column -- config-as-code only, set via `.loopover.yml settings.openPrFileCollisionMode`. */ openPrFileCollisionMode?: "inherit" | "off" | "enabled" | undefined; + /** Issue-planning command (#issue-coding-plan): per-repo override of the `@loopover plan` issue command, + * gated globally by `LOOPOVER_REVIEW_PLANNER` (itself default-OFF -- see `review/planner.ts`'s + * `isPlannerEnabled` doc comment). `"inherit"` (the default, mirrors duplicateWinnerMode/ + * openPrFileCollisionMode's shape) defers to the global env default; `"off"`/`"enabled"` fully override + * the global default in either direction for this repo. No DB column -- config-as-code only, set via + * `.loopover.yml settings.plannerMode`. */ + plannerMode?: "inherit" | "off" | "enabled" | undefined; /** Review-evasion protection (#review-evasion-protection): a contributor closing or converting their OWN * PR to draft while loopover has an ACTIVE review pass running against it is dodging the one-shot * review process. The effective default is `"close"` as of #4011 (see `normalizeReviewEvasionProtection` diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 32772f3084..408700d603 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -310,6 +310,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { closeOwnerAuthors: "closeOwnerAuthors:", duplicateWinnerMode: "duplicateWinnerMode:", openPrFileCollisionMode: "openPrFileCollisionMode:", + plannerMode: "plannerMode:", autoLabelEnabled: "autoLabelEnabled:", typeLabelsEnabled: "typeLabelsEnabled:", badgeEnabled: "badgeEnabled:", diff --git a/test/unit/planner-mode.test.ts b/test/unit/planner-mode.test.ts new file mode 100644 index 0000000000..082f5969c8 --- /dev/null +++ b/test/unit/planner-mode.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { resolvePlannerEnabled } from "../../src/settings/planner-mode"; + +describe("resolvePlannerEnabled", () => { + it("inherit defers to the global default in both directions", () => { + expect(resolvePlannerEnabled(true, "inherit")).toBe(true); + expect(resolvePlannerEnabled(false, "inherit")).toBe(false); + }); + + it("null/undefined mode behaves the same as inherit", () => { + expect(resolvePlannerEnabled(true, null)).toBe(true); + expect(resolvePlannerEnabled(false, undefined)).toBe(false); + }); + + it("off fully overrides a globally-ON default", () => { + expect(resolvePlannerEnabled(true, "off")).toBe(false); + }); + + it("enabled fully overrides a globally-OFF default (symmetric)", () => { + expect(resolvePlannerEnabled(false, "enabled")).toBe(true); + }); +}); diff --git a/test/unit/queue-3.test.ts b/test/unit/queue-3.test.ts index 8f3660a93a..f4fdf80611 100644 --- a/test/unit/queue-3.test.ts +++ b/test/unit/queue-3.test.ts @@ -500,6 +500,123 @@ describe("queue processors", () => { expect(skip?.detail).toBe("no_plan_generated"); }); + it("planner (#issue-coding-plan-config): a per-repo plannerMode: enabled override turns the command ON even when LOOPOVER_REVIEW_PLANNER is unset (fleet default off)", async () => { + const run = vi.fn(async () => ({ response: "## Summary\nPer-repo override plan." })); + // LOOPOVER_REVIEW_PLANNER deliberately absent -- the fleet default is off, so this proves the repo override + // (not the env var) is what turns the command on. + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { plannerMode: "enabled" } }, "repo_file"); + let postedBody: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@loopover plan", "maintainer1")); + expect(run).toHaveBeenCalledTimes(1); + expect(postedBody).toContain("Per-repo override plan"); + }); + + it("planner (#issue-coding-plan-config): a per-repo plannerMode: off override turns the command OFF even when LOOPOVER_REVIEW_PLANNER=true (fleet default on)", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), LOOPOVER_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { plannerMode: "off" } }, "repo_file"); + 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/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@loopover plan", "maintainer1")); + expect(run).not.toHaveBeenCalled(); + // Not consumed at all (byte-identical to the fleet-off path) -- falls through, no skip audit either. + const planAudits = await env.DB.prepare("select count(*) as n from audit_events where event_type in (?, ?)").bind("github_app.issue_plan_skipped", "github_app.issue_plan_generated").first<{ n: number }>(); + expect(planAudits?.n).toBe(0); + }); + + it("planner (#issue-coding-plan-config): plannerMode: inherit explicitly defers to the fleet default (both directions)", async () => { + const runOff = vi.fn(async () => ({ response: "should not run" })); + const envOff = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), LOOPOVER_REVIEW_PLANNER: "false", AI: { run: runOff } as unknown as Ai }); + await setupPlannerRepo(envOff); + await upsertRepoFocusManifest(envOff, "JSONbored/gittensory", { settings: { plannerMode: "inherit" } }, "repo_file"); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(envOff, plannerWebhook("@loopover plan", "maintainer1")); + expect(runOff).not.toHaveBeenCalled(); + + const runOn = vi.fn(async () => ({ response: "## Summary\nInherit-on plan." })); + const envOn = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), LOOPOVER_REVIEW_PLANNER: "true", AI: { run: runOn } as unknown as Ai }); + await setupPlannerRepo(envOn); + await upsertRepoFocusManifest(envOn, "JSONbored/gittensory", { settings: { plannerMode: "inherit" } }, "repo_file"); + let postedBody: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(envOn, plannerWebhook("@loopover plan", "maintainer1")); + expect(runOn).toHaveBeenCalledTimes(1); + expect(postedBody).toContain("Inherit-on plan"); + }); + + it("planner (#issue-coding-plan-config): a manifest-load failure degrades to the fleet-only default (fail-safe), never throws into the webhook loop", async () => { + const run = vi.fn(async () => ({ response: "## Summary\nFail-safe plan." })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), LOOPOVER_REVIEW_PLANNER: "true", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + const loadSpy = vi.spyOn(focusManifestLoaderModule, "loadRepoFocusManifest").mockRejectedValueOnce(new Error("manifest unavailable")); + let postedBody: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await expect(processJob(env, plannerWebhook("@loopover plan", "maintainer1"))).resolves.not.toThrow(); + // A rejected manifest load degrades to the fleet-only default (LOOPOVER_REVIEW_PLANNER=true here), so the + // command still runs -- the failure never blocks or throws. + expect(run).toHaveBeenCalledTimes(1); + expect(postedBody).toContain("Fail-safe plan"); + loadSpy.mockRestore(); + }); + + it("planner (#issue-coding-plan-config): no repository on the payload falls back to the global-only default without attempting a manifest load", async () => { + const run = vi.fn(async () => ({ response: "should not run" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), LOOPOVER_REVIEW_PLANNER: "false", AI: { run } as unknown as Ai }); + await setupPlannerRepo(env); + const loadSpy = vi.spyOn(focusManifestLoaderModule, "loadRepoFocusManifest"); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(env, { + type: "github-webhook", + deliveryId: "plan-no-repo", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + issue: { number: 77, title: "Issue", state: "open", user: { login: "reporter" }, body: "b" }, + comment: { body: "@loopover plan", user: { login: "maintainer1", type: "User" } }, + sender: { login: "maintainer1", type: "User" }, + }, + } as unknown as Parameters[1]); + expect(loadSpy).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + loadSpy.mockRestore(); + }); + it("configuration (#2168): a maintainer @loopover configuration posts the effective resolved config", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await setupPlannerRepo(env);