Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9681,6 +9681,14 @@
"off",
"enabled"
]
},
"plannerMode": {
"type": "string",
"enum": [
"inherit",
"off",
"enabled"
]
}
},
"required": [
Expand Down
7 changes: 7 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export type FocusManifestSettings = Partial<
| "skipAutomationBotAuthors"
| "duplicateWinnerMode"
| "openPrFileCollisionMode"
| "plannerMode"
| "autoLabelEnabled"
| "typeLabelsEnabled"
| "badgeEnabled"
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 25 additions & 10 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<boolean> {
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) {
Expand Down
15 changes: 10 additions & 5 deletions src/review/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions src/settings/planner-mode.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
22 changes: 22 additions & 0 deletions test/unit/planner-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading