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, `