From 44f59ca196bcb834fecd60b8ccc8eed22420730c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:49:26 -0700 Subject: [PATCH] feat(review): add config-as-code settings surface for the issue-planning tool Adds RepositorySettings.issuePlanEnabled/issuePlanExtraLabels/ issuePlanMilestoneReuse (config-as-code only, no DB column, matching the Batch A/B/C pattern) so a selfhoster can opt loopover_plan_repo_issues out per repo, suggest additional labels beyond the repo's real ones, and control whether milestone creation reuses an existing open milestone by title match or always creates fresh. Wires the fields into src/services/issue-plan-draft.ts: settings are now fetched once per call (previously only fetched when gittensor- enrolled) and reused for both the enable-check and the existing gittensor label-enrichment path, removing its own duplicate fetch. Adds matching type/parser wiring in packages/loopover-engine's FocusManifest, OpenAPI schema entries, .loopover.yml.example and config/examples/loopover.full.yml documentation (kept byte-identical per config-templates.test.ts), and a short selfhoster docs section. Closes #7429 --- .loopover.yml.example | 21 ++++++ .../docs/self-hosting-configuration.mdx | 8 +++ apps/loopover-ui/public/openapi.json | 12 ++++ config/examples/loopover.full.yml | 21 ++++++ .../loopover-engine/src/focus-manifest.ts | 18 ++++- .../src/types/manifest-deps-types.ts | 18 +++++ src/db/repositories.ts | 9 +++ src/openapi/schemas.ts | 5 ++ src/services/issue-plan-draft.ts | 49 +++++++------- src/types.ts | 18 +++++ test/unit/focus-manifest.test.ts | 3 + test/unit/issue-plan-draft.test.ts | 65 +++++++++++++++++-- 12 files changed, 219 insertions(+), 28 deletions(-) diff --git a/.loopover.yml.example b/.loopover.yml.example index 277d60434b..5e4275a5c5 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -849,6 +849,27 @@ settings: # dependency: area:dependency # custom: maintainer:custom + # Per-repo opt-out for `loopover_plan_repo_issues` (#7429), the selfhoster-facing AI issue-planning MCP + # tool -- when false, the tool returns a `disabled` status without ever calling the model. The tool is + # already gated behind MCP maintainer access and the fleet-wide AI_SUMMARIES_ENABLED/ + # AI_PUBLIC_COMMENTS_ENABLED switches, so this is an additional per-repo layer, not the primary gate. + # Bool. Default: true. + issuePlanEnabled: true + + # Additional label names `loopover_plan_repo_issues` suggests to the model alongside the repo's real + # GitHub labels and (when gittensor-enrolled) its type-label taxonomy -- e.g. a project-specific + # category not yet created as a real label. Purely additive; never removes or replaces the repo's own + # labels. Array of strings. Default: []. + # issuePlanExtraLabels: + # - area:docs + # - needs-triage + + # Whether `loopover_plan_repo_issues` attempts to reuse an existing OPEN milestone by exact + # normalized-title match before creating a new one. A repo that always wants a fresh milestone per + # planning session (e.g. date-stamped titles) can set this false to skip the reuse lookup entirely. + # Bool. Default: true. + issuePlanMilestoneReuse: true + # Linked-issue label propagation (#priority-linked-issue-gate): the ONLY mechanism that can select a # label like `gittensor:priority` -- it is NEVER inferred from a PR's title, changed files, AI # output, or existing PR labels, only ever copied from a linked/closing issue ("Fixes #123") that diff --git a/apps/loopover-ui/content/docs/self-hosting-configuration.mdx b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx index 775fa479bd..a73a1cb3c0 100644 --- a/apps/loopover-ui/content/docs/self-hosting-configuration.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx @@ -366,6 +366,14 @@ A cluster of contributor-abuse guardrails, all config-as-code only, all off/unse - **Exemptions and account age** — `autoCloseExemptLogins` is a shared, repo-scoped list of logins never throttled or closed by these deterministic mechanisms, on top of the standing owner/admin/bot exemption. `accountAgeThresholdDays` (default `null`, off) applies `newAccountLabel` (default `new-account`) to a PR from a below-threshold-age account — friction/visibility only, never an automatic close on account age alone, and never for the owner, admins, or bots. - **Command rate limit** — `commandRateLimitPolicy` (`off`/`hold`, default `off`) generalizes the review-nag pattern to every `@loopover` command, not just review-request pings. `commandRateLimitMaxPerWindow` (default `20`) bounds cheap, cache-only commands; `commandRateLimitAiMaxPerWindow` (default `5`) is the tighter limit for AI-cost-bearing commands (ask/blockers/preflight/etc.); `commandRateLimitWindowHours` (default `24`) is the rolling window both limits count against. +### settings.issuePlan* + +Config for `loopover_plan_repo_issues`, the AI issue-planning MCP tool: given a maintainer-supplied free-form goal, it proposes a small set of GitHub issue drafts (dry-run by default) and, on explicit request, creates them via the same installation-token/Orb-broker path every other GitHub write in this repo uses — never a flat PAT, so it only works on a repo the App/Orb is actually installed on. + +- `issuePlanEnabled` — per-repo opt-out. The tool is already gated behind MCP maintainer access and the fleet-wide `AI_SUMMARIES_ENABLED`/`AI_PUBLIC_COMMENTS_ENABLED` switches; this is an additional layer for a repo that wants those switches on generally but this specific capability off. Default `true`. +- `issuePlanExtraLabels` — additional label names suggested to the model alongside the repo's real GitHub labels (and, on a gittensor-enrolled repo, its type-label taxonomy) — useful for a project-specific category not yet created as a real label. Purely additive; never removes or replaces the repo's own labels. Default `[]`. +- `issuePlanMilestoneReuse` — whether the tool reuses an existing open milestone by exact normalized-title match before creating a new one, when the caller asks it to target a milestone. Set `false` for a repo that always wants a fresh milestone per planning session (e.g. date-stamped titles). Default `true`. + ### contentLane Lets a self-hosted maintainer point LoopOver at their own structured registry (a subnet/plugin/package catalog, for example) without a LoopOver code change — reviewing additions to a data file the same way it reviews code. Unconfigured by default; uncomment and set at least `entryFileGlob` and `collectionField` (both required — the whole block is ignored with a warning if either is missing). diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index ed39da4735..ce003791dd 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9693,6 +9693,18 @@ "close" ], "description": "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion." + }, + "issuePlanEnabled": { + "type": "boolean" + }, + "issuePlanExtraLabels": { + "type": "array", + "items": { + "type": "string" + } + }, + "issuePlanMilestoneReuse": { + "type": "boolean" } }, "required": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 7060c96bdd..3fe6e7dd2d 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -863,6 +863,27 @@ settings: # dependency: area:dependency # custom: maintainer:custom + # Per-repo opt-out for `loopover_plan_repo_issues` (#7429), the selfhoster-facing AI issue-planning MCP + # tool -- when false, the tool returns a `disabled` status without ever calling the model. The tool is + # already gated behind MCP maintainer access and the fleet-wide AI_SUMMARIES_ENABLED/ + # AI_PUBLIC_COMMENTS_ENABLED switches, so this is an additional per-repo layer, not the primary gate. + # Bool. Default: true. + issuePlanEnabled: true + + # Additional label names `loopover_plan_repo_issues` suggests to the model alongside the repo's real + # GitHub labels and (when gittensor-enrolled) its type-label taxonomy -- e.g. a project-specific + # category not yet created as a real label. Purely additive; never removes or replaces the repo's own + # labels. Array of strings. Default: []. + # issuePlanExtraLabels: + # - area:docs + # - needs-triage + + # Whether `loopover_plan_repo_issues` attempts to reuse an existing OPEN milestone by exact + # normalized-title match before creating a new one. A repo that always wants a fresh milestone per + # planning session (e.g. date-stamped titles) can set this false to skip the reuse lookup entirely. + # Bool. Default: true. + issuePlanMilestoneReuse: true + # Linked-issue label propagation (#priority-linked-issue-gate): the ONLY mechanism that can select a # label like `gittensor:priority` -- it is NEVER inferred from a PR's title, changed files, AI # output, or existing PR labels, only ever copied from a linked/closing issue ("Fixes #123") that diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 02aebe9447..54572a4a4f 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -542,6 +542,9 @@ export type FocusManifestSettings = Partial< | "plannerMode" | "autoLabelEnabled" | "typeLabelsEnabled" + | "issuePlanEnabled" + | "issuePlanExtraLabels" + | "issuePlanMilestoneReuse" | "badgeEnabled" | "publicQualityMetrics" | "gittensorLabel" @@ -2402,7 +2405,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "aiReviewConfirmedContributorsOnly", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "hardGuardrailGlobsOverridesInvariants"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "aiReviewConfirmedContributorsOnly", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "issuePlanEnabled", "issuePlanMilestoneReuse", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "hardGuardrailGlobsOverridesInvariants"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } @@ -2658,6 +2661,19 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } else if (r.hardGuardrailGlobs !== undefined) { warnings.push(`Manifest "settings.hardGuardrailGlobs" must be an array of path globs; ignoring it and keeping any existing guardrails.`); } + // Additional label names loopover_plan_repo_issues (#7429) suggests to the model, purely additive to the + // repo's real GitHub labels -- same array-replace shape as hardGuardrailGlobs above (explicit [] or a + // non-empty valid list replaces; malformed/omitted is ignored, never clears an existing DB value). + if (Array.isArray(r.issuePlanExtraLabels)) { + const issuePlanExtraLabels = normalizeStringList(r.issuePlanExtraLabels, "settings.issuePlanExtraLabels", warnings); + if (r.issuePlanExtraLabels.length === 0 || issuePlanExtraLabels.length > 0) { + out.issuePlanExtraLabels = issuePlanExtraLabels; + } else { + warnings.push(`Manifest "settings.issuePlanExtraLabels" did not contain any valid label names; ignoring it and keeping any existing value.`); + } + } else if (r.issuePlanExtraLabels !== undefined) { + warnings.push(`Manifest "settings.issuePlanExtraLabels" must be an array of label names; ignoring it and keeping any existing value.`); + } // Manual-review label is deliberately separate from review_state_label so operators can use one hold label // without enabling the old ready/changes disposition labels. Null disables only the label, not the hold. if (r.manualReviewLabel === null) { diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index bd3c09b28e..5cf7292336 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -381,6 +381,24 @@ export type RepositorySettings = { * built-in three (e.g. `security: "area:security"`) for its own taxonomy. Always populated by the DB * layer; optional so existing settings fixtures/callers need not be touched. */ typeLabels?: PrTypeLabelSet | undefined; + /** Per-repo opt-out for `loopover_plan_repo_issues` (#7429, the selfhoster-facing AI issue-planning MCP + * tool, `src/services/issue-plan-draft.ts`, #7426): when explicitly `false`, the tool returns a `disabled` + * status without ever calling the model. Default TRUE — the tool is already gated behind MCP + * `requireRepoManageAccess` and the fleet-wide `AI_SUMMARIES_ENABLED`/`AI_PUBLIC_COMMENTS_ENABLED` + * switches, so this is an ADDITIONAL per-repo layer, not the primary gate. Config-as-code only (no DB + * column, matching the Batch A/B/C pattern, #6442-#6444) — settable via `.loopover.yml settings:` only. */ + issuePlanEnabled?: boolean | undefined; + /** Additional label names `loopover_plan_repo_issues` (#7429) suggests to the model alongside the repo's + * real GitHub labels and (when gittensor-enrolled) its type-label taxonomy — e.g. a project-specific + * category not yet created as a real label. Purely additive; never removes or replaces the repo's own + * labels. Default empty. Config-as-code only (no DB column) — settable via `.loopover.yml settings:` only. */ + issuePlanExtraLabels?: string[] | undefined; + /** Whether `loopover_plan_repo_issues` (#7429) attempts to reuse an existing OPEN milestone by exact + * normalized-title match before creating a new one (`resolveOrCreateIssuePlanMilestone`, + * `src/services/issue-plan-draft.ts`). Default TRUE. A repo that always wants a fresh milestone per + * planning session (e.g. date-stamped titles) can set this `false` to skip the reuse lookup entirely. + * Config-as-code only (no DB column) — settable via `.loopover.yml settings:` only. */ + issuePlanMilestoneReuse?: boolean | undefined; /** Linked-issue label propagation (#priority-linked-issue-gate): the ONLY mechanism that can ever * select the configured priority label (or any other configured mapping's PR label) — never * inferred from a PR's title, changed files, AI output, or existing PR labels. Default disabled diff --git a/src/db/repositories.ts b/src/db/repositories.ts index dee5c82499..601958f952 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -603,6 +603,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise autoLabelEnabled: true, typeLabelsEnabled: true, typeLabels: { ...DEFAULT_TYPE_LABELS }, + issuePlanEnabled: true, + issuePlanExtraLabels: [], + issuePlanMilestoneReuse: true, linkedIssueLabelPropagation: { ...DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, mappings: [] }, linkedIssueHardRules: { ...DEFAULT_LINKED_ISSUE_HARD_RULES, pointBearingLabels: [], maintainerOnlyLabels: [] }, gittensorLabel: "gittensor", @@ -694,6 +697,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise // linkedIssueLabelPropagation now always resolves to the built-in default, never a stale DB customization). typeLabelsEnabled: true, typeLabels: { ...DEFAULT_TYPE_LABELS }, + issuePlanEnabled: true, + issuePlanExtraLabels: [], + issuePlanMilestoneReuse: true, linkedIssueLabelPropagation: { ...DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, mappings: [] }, linkedIssueHardRules: { ...DEFAULT_LINKED_ISSUE_HARD_RULES, pointBearingLabels: [], maintainerOnlyLabels: [] }, gittensorLabel: "gittensor", @@ -829,6 +835,9 @@ export async function upsertRepositorySettings(env: Env, settings: Partial label name` record (#label-modularity): bug/feature/priority are the built-in // categories, but a self-hoster may register any number of additional ones (e.g. `security`). typeLabels: z.record(z.string(), z.string()).optional(), + // loopover_plan_repo_issues config-as-code surface (#7429) -- see the field's own doc comment on + // RepositorySettings (src/types.ts) for the full contract. + issuePlanEnabled: z.boolean().optional(), + issuePlanExtraLabels: z.array(z.string()).optional(), + issuePlanMilestoneReuse: z.boolean().optional(), linkedIssueLabelPropagation: z .object({ enabled: z.boolean(), diff --git a/src/services/issue-plan-draft.ts b/src/services/issue-plan-draft.ts index 3be09af2cc..49e0e0b8f0 100644 --- a/src/services/issue-plan-draft.ts +++ b/src/services/issue-plan-draft.ts @@ -41,7 +41,7 @@ import { resolveRepositorySettings } from "../settings/repository-settings"; import { isFocusManifestPublicSafe } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { normalizeIssueTitleKey } from "./contributor-issue-draft"; -import type { IssueRecord } from "../types"; +import type { IssueRecord, RepositorySettings } from "../types"; import { sha256Hex } from "../utils/crypto"; import { errorMessage, nowIso } from "../utils/json"; @@ -237,19 +237,16 @@ function buildIssuePlanUserPrompt(goal: string, existingLabelNames: string[]): s * to choose from. Label NAMES only, never any scoring weight/multiplier -- those never leave the private * scoring pipeline. * - * ZERO FOOTPRINT when the plugin is off (the fleet-wide default, matching gittensor-wire.ts's own contract): the - * global flag check short-circuits before this ever loads a manifest or repository settings, so a plain - * self-host instance with no gittensor affiliation makes no additional reads for this at all. - * - * Uses resolveRepositorySettings (DB overlaid with `.loopover.yml`), NOT the raw DB-only getRepositorySettings: - * typeLabels/typeLabelsEnabled are config-as-code only now (no DB column backs them, #6443) -- reading the raw - * DB row would silently ignore a repo's actual configured taxonomy and always see the built-in default. + * ZERO FOOTPRINT when the plugin is off (the fleet-wide default, matching gittensor-wire.ts's own contract): + * the global flag check short-circuits before this ever loads a manifest, so a plain self-host instance with + * no gittensor affiliation makes no additional reads for the gittensor-specific check itself -- `settings` is + * passed in already resolved (#7429 needs it unconditionally for issuePlanEnabled/issuePlanExtraLabels, so + * fetching it again here just for the gittensor path would be redundant work, not a footprint reduction). */ -async function resolveGittensorLabelEnrichment(env: Env, repoFullName: string): Promise { +async function resolveGittensorLabelEnrichment(env: Env, repoFullName: string, settings: RepositorySettings): Promise { if (!isGittensorPluginEnabled(env)) return []; const manifest = await loadRepoFocusManifest(env, repoFullName); if (!shouldEnableGittensorForRepo(env, manifest.experimental.gittensor)) return []; - const settings = await resolveRepositorySettings(env, repoFullName); if (settings.typeLabelsEnabled !== true) return []; return Object.values(settings.typeLabels ?? DEFAULT_TYPE_LABELS); } @@ -300,15 +297,20 @@ export type IssuePlanMilestoneTarget = { title: string; description?: string | u * the maintainer still gets their issues, just ungrouped, which is preferable to filing nothing at all over a * milestone-specific glitch. Titles/descriptions/due dates are the CALLER's own input, never model-generated * (see the epic's #7427 boundary: milestone metadata is maintainer-authored/approved, not invented by the AI). + * + * `attemptReuse` (#7429, RepositorySettings.issuePlanMilestoneReuse, default true) skips the reuse lookup + * entirely when false -- for a repo that always wants a fresh milestone per planning session (e.g. date- + * stamped titles) -- going straight to create. */ async function resolveOrCreateIssuePlanMilestone( env: Env, installationId: number, repoFullName: string, target: IssuePlanMilestoneTarget, + attemptReuse: boolean, ): Promise { try { - const titleKey = normalizeIssueTitleKey(target.title); + const titleKey = attemptReuse ? normalizeIssueTitleKey(target.title) : ""; if (titleKey) { const existing = await listOpenInstallationMilestones(env, installationId, repoFullName); const match = existing.find((milestone) => normalizeIssueTitleKey(milestone.title) === titleKey); @@ -371,13 +373,11 @@ function emptyIssuePlanResult( * GitHub, via the installation-token/Orb-broker path only (#7425) -- there is no flat-PAT fallback, so a repo * with no installation degrades every draft to skipped_create_failed rather than failing this call outright. * - * Unlike review/planner.ts's generateIssuePlan (which relies purely on its OWN dedicated isPlannerEnabled/ - * settings.plannerMode gate, checked by its webhook-triggered caller), this function has no per-repo enable - * flag of its own -- it is opt-in simply by a maintainer calling the MCP tool that wraps it. It still checks the - * SAME fleet-wide AI_SUMMARIES_ENABLED/AI_PUBLIC_COMMENTS_ENABLED kill switches runLoopOverAiReview uses, so an - * operator who has globally disabled AI-generated public content is never surprised by this tool posting any -- - * a reasonable substitute for a dedicated flag given this capability's primary safety layer is the MCP access - * check (requireRepoManageAccess), not fleet-wide default-off config-as-code. + * Also checks the SAME fleet-wide AI_SUMMARIES_ENABLED/AI_PUBLIC_COMMENTS_ENABLED kill switches + * runLoopOverAiReview uses, so an operator who has globally disabled AI-generated public content is never + * surprised by this tool posting any -- this capability's primary safety layer is the MCP access check + * (requireRepoManageAccess), not fleet-wide default-off config-as-code. `RepositorySettings.issuePlanEnabled` + * (#7429, config-as-code only, default true) is an ADDITIONAL per-repo opt-out layered on top of both. */ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, goal: string, options: IssuePlanDraftOptions = {}): Promise { const generatedAt = nowIso(); @@ -392,16 +392,21 @@ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, go const trimmedGoal = goal.trim().slice(0, MAX_GOAL_CHARS); if (!trimmedGoal) return empty("no_output"); - const [repo, openIssues, declinedIssues, labels, gittensorTypeLabels] = await Promise.all([ + const [repo, openIssues, declinedIssues, labels, settings] = await Promise.all([ getRepository(env, repoFullName), listOpenIssues(env, repoFullName), listClosedContributorDraftIssues(env, repoFullName, `