Skip to content

Latest commit

 

History

History
 
 

README.md

Self-host private config — layout, precedence, and examples

This directory ships generic, safe examples for the self-host private config directory (LOOPOVER_REPO_CONFIG_DIR, default /config in the Docker image / docker-compose.yml). It contains no real policy, thresholds, logins, or repo names — copy what you need into your own mounted config directory and edit it there (never in this repo).

See TEMPLATES.md for the full template catalog (minimal + exhaustive loopover.yml starters, public-vs-private usage, and fleet notes for loopover, awesome-claude, and metagraphed without committing private policy).

The private config directory is read by src/selfhost/private-config.ts and is kept out of the public GitHub repo on purpose: contributors can read a public manifest, so anti-abuse thresholds, maintainer/admin allowlists, autonomy dials, and model/effort settings belong here instead, where only the self-host operator can see them.

The canonical manifest filename is .loopover.yml.

Directory layout

For a repo owner/repo, the reader tries, in priority order:

${LOOPOVER_REPO_CONFIG_DIR}/owner__repo/.loopover.yml     # 1. owner-qualified folder (recommended)
${LOOPOVER_REPO_CONFIG_DIR}/repo/.loopover.yml            # 2. bare repo-name folder
${LOOPOVER_REPO_CONFIG_DIR}/owner__repo.yml               # 3. flat file (back-compat, brand-agnostic)
${LOOPOVER_REPO_CONFIG_DIR}/.loopover.yml                 # 4. global default, shared by every repo
${LOOPOVER_REPO_CONFIG_DIR}/_shared/.loopover.yml         # 5. shared base (#1959), lowest priority

.yaml and .json are accepted everywhere .yml is. Every one of these files uses the exact same schema as the public manifest — see loopover.full.yml (or .loopover.yml.example at the repo root) for the exhaustive, field-by-field reference. For the smallest safe starter, copy loopover.minimal.yml (or .loopover.minimal.yml) to your repo root as .loopover.yml or into your private mount and customize from there.

Precedence chain

From highest to lowest priority:

  1. Private per-repo file, deep-merged over 2 and 3 when more than one exists (see below) — or used alone when it is the only private layer present.
  2. Private global default (${LOOPOVER_REPO_CONFIG_DIR}/.loopover.yml) — deep-merged under 1 when both exist; used alone when a repo has no per-repo file of its own and no shared base is mounted.
  3. Private shared base (${LOOPOVER_REPO_CONFIG_DIR}/_shared/.loopover.yml, #1959) — the lowest-priority private layer, deep-merged under both 1 and 2. An operator running many repos writes a house review policy (e.g. a default review.tone, path_filters, or exclude_paths) here once instead of copy-pasting it into every repo's per-repo file or the global default. .yaml/.json are accepted, same as every other candidate. Absent (the default, common case) ⇒ byte-identical behavior to the pre-#1959 2-layer chain.
  4. When none of the three private layers above exists, the loader falls back to the public repo .loopover.yml (or .github/loopover.yml) fetched from GitHub.
  5. Dashboard/API-stored settings for the repo.
  6. Built-in safe defaults.

Layers 1-3 are evaluated together as one private-config layer: if any of a per-repo file, a global default, or a shared base exists privately, the public file in layer 4 is never consulted for that repo. This is unchanged from the original private-config behavior (#1390) — only the interaction among the three private layers is new (the per-repo/global interaction shipped first; the shared base is the newest, lowest layer, #1959).

This chain governs per-repo review policy only. A separate, lower-level set of deployment environment variables (LOOPOVER_REVIEW_* flags, AI provider keys/models, self-host runtime knobs, etc.) configures the deployment itself and sits underneath all 5 layers above — a .loopover.yml/private-config value never overrides an operator's env-level kill-switch, it only narrows what's already permitted. See the generated, always-current SELFHOST_ENV_REFERENCE_ROWS (built by npm run selfhost:env-reference from every env.SOMETHING read in the codebase) for the full list.

Overlay (deep-merge) semantics

When two or more of {a per-repo file, a global default, a shared base} exist for a repo, they are merged in ascending priority — shared base first, global default overlaid on top of that, then the per-repo file overlaid on top of that (see Shared base layer below for the shared base specifically):

  • Nested mappings (gate, settings, review, features, contentLane, and their own nested blocks like gate.readiness or gate.aiReview) merge key by key. A higher-priority file only needs to mention the keys it wants to change; everything else is inherited from the next layer down.
  • Arrays (wantedPaths, preferredLabels, testExpectations, review.pathInstructions, review.excludePaths, contentLane.duplicateKeyFields, etc.) replace wholesale — a higher-priority array is never concatenated with a lower layer's.
  • An explicit null at a key in a higher-priority file always overrides a lower layer's value there. This clears a setting wherever the manifest parser already treats an explicit null as "off"/"clear" — e.g. settings.contributorOpenPrCap, settings.contributorOpenIssueCap, settings.accountAgeThresholdDays, and the enforcement label names (settings.blacklistLabel/contributorCapLabel/reviewNagLabel, see below) — and is a harmless no-op (equivalent to omitting the key) everywhere else.
  • If any layer fails to parse (or is malformed/oversized), it is dropped from the merge and the remaining, still-valid layers merge as if it were never mounted; a still-good layer's policy is never silently discarded just because another layer is broken, and a broken layer never blocks a review.

Example 1 — global defaults + a per-repo override

.loopover.yml (global default, at the config dir root):

settings:
  contributorOpenPrCap: 3
  autoCloseExemptLogins:
    - your-admin-login
gate:
  enabled: true
  duplicates: block

owner__repo/.loopover.yml (per-repo override — only touches what's different for this repo):

gate:
  enabled: true
  # duplicates is inherited from global (still "block") — not repeated here.
  aiReview:
    mode: advisory

The effective config for owner/repo has gate.duplicates: block (from global), gate.aiReview.mode: advisory and gate.enabled: true (from the per-repo file), and settings.contributorOpenPrCap: 3 plus the exempt login (both from global).

Example 2 — disabling a global setting for one high-trust repo

# owner__repo/.loopover.yml
settings:
  contributorOpenPrCap: null   # explicitly clears the global cap of 3 for this repo only

Example 3 — an admin/maintainer exemption list

Shared anti-abuse mechanisms (the review-request-nag cooldown, the contributor open-item cap) exempt configured logins on top of the standing owner/admin/automation-bot exemption:

# .loopover.yml (global default)
settings:
  autoCloseExemptLogins:
    - your-trusted-regular

Shared base layer (multi-repo operators, #1959)

An operator running many repos through the same self-host instance can express one house review policy — e.g. a default review.tone, a baseline path_filters/wantedPaths set, or common exclude_pathsonce, instead of copy-pasting it into every repo's per-repo file or even the global default. That policy lives at:

${LOOPOVER_REPO_CONFIG_DIR}/_shared/.loopover.yml

(.yaml/.json also accepted, same lookup order as every other candidate — see shared.loopover.yml for a starter). It sits at the lowest priority of the three private layers: a per-repo file overlays a global default, which overlays the shared base — the shared base fills in only the fields a higher layer is silent on. This is the exact same deep-merge helper and array-replace/explicit-null-clear semantics described above, folded across one more layer; it is not a new merge algorithm.

Absent shared base is the default, common case — with no _shared/.loopover.yml mounted, behavior is byte-identical to the pre-#1959 2-layer chain. A malformed or unreadable shared file fails safe exactly like a malformed per-repo or global file always has: it is dropped from the merge and the remaining, still-valid layers combine as if it were never mounted — a broken shared base never blocks a review. When a shared review: block contributes, the parsed manifest carries review.sharedConfigSource (runtime provenance only, #2046) with the relative path of the shared file that supplied the base layer.

Example 4 — shared base + global default + a per-repo override, all three present

_shared/.loopover.yml (shared base — one house policy for every repo on this instance):

review:
  tone: friendly-terse
gate:
  duplicates: block

.loopover.yml (global default — this instance's own baseline, silent on review.tone):

gate:
  enabled: true

owner__repo/.loopover.yml (per-repo override — only touches what's different for this repo):

gate:
  enabled: true
  # duplicates is inherited from the shared base (still "block") — neither this file nor the
  # global default repeats it.

The effective config for owner/repo has review.tone: friendly-terse (from the shared base; neither global nor the per-repo file mentions it), gate.duplicates: block (from the shared base, passed through untouched by global), and gate.enabled: true (set the same way by both global and the per-repo file).

Label autonomy scoping for one-shot review mode

Two autonomy classes govern every label the bot can apply, and they are independent:

  • close authorizes the terminal merge/close/hold disposition and the anti-abuse enforcement labels tied to it (blacklist/contributor-cap/review-nag) — a label like over-contributor-limit is inseparable metadata on its close, so it never needs a separate grant. Set settings.contributorCapLabel/blacklistLabel/reviewNagLabel to explicit null (not just omitted) to close/hold without applying any label at all.
  • review_state_label authorizes the bot's own disposition-communication labels only — ready-to-merge / changes-requested / manual-review / migration-collision by default. These are advisory commentary about the bot's own verdict, not enforcement, and default OFF like every autonomy class. For a one-shot review model, leave this at the default so a PR merges, closes, or holds through the required gate check alone — set it to auto only if you specifically want that commentary as GitHub labels too.

All disposition labels are configurable under settings.*Label, and explicit null disables the label without disabling the underlying merge/close/hold decision. Hard path guardrails are config-as-code only and safe by default (#3943): settings.hardGuardrailGlobs is ADDED to a fixed, built-in invariant set (DEFAULT_HARD_GUARDRAIL_GLOBS in src/review/guardrail-config.ts) — an ordinary edit can only ever widen guardrail coverage, never shrink it. Set settings.hardGuardrailGlobsOverridesInvariants: true to opt out of that safety net entirely: once set, hardGuardrailGlobs REPLACES (never merges with) the built-in set or any lower-layer private global default, including an explicit [] to disable path guardrails altogether — a deliberate, separately-visible decision, not a side effect of trimming the list.

# .loopover.yml (global default) — recommended one-shot baseline
settings:
  autonomy:
    close: auto
    # review_state_label intentionally omitted (defaults to observe)

The broad autonomy.label class still exists but no longer gates any of the above — it is not required for either family and applies to nothing on its own.

Maintainer-mention nag moderation

settings.reviewNagMonitoredMentions extends the @loopover-ping review-nag cooldown (reviewNagPolicy/reviewNagMaxPings/reviewNagCooldownDays/reviewNagLabel — same settings, one shared policy) to also throttle a thread's own author repeatedly @-mentioning a configured maintainer login, counted independently per login and independently of the @loopover counter:

# .loopover.yml (global default)
settings:
  reviewNagPolicy: hold
  reviewNagMonitoredMentions:
    - your-maintainer-login

Owner/admin/automation-bot logins and anyone on autoCloseExemptLogins are always exempt, and only the thread's own author is ever throttled — a third party mentioning the login on someone else's PR/issue never counts.

Linked-issue label propagation

settings.linkedIssueLabelPropagation copies a label from a linked/closing issue onto the PR when the issue already carries it — the only mechanism that can ever select a maintainer-reward or moderation-weighted label; it is never inferred from a PR's title, changed files, AI output, or existing PR labels. If your labels carry that kind of weight, this is exactly the sort of rule that belongs in the private layer rather than the public manifest, so a contributor can see that the mapping exists (via its effect) without being able to read the exact issue-label -> PR-label rules and game them:

# .loopover.yml (global default)
settings:
  linkedIssueLabelPropagation:
    enabled: true
    mode: exclusive_type_label
    mappings:
      - issueLabel: customer:vip
        prLabel: triage:vip
        removeOtherTypeLabels: false

A per-repo override's mappings list replaces the global default wholesale (the standard array-replace overlay semantics above) — it does not merge with it.

What belongs here vs. in the public .loopover.yml

  • Private config (this directory): anti-abuse thresholds, the contributor cap, maintainer/ admin exemption logins, autonomy dials, model/effort overrides, and anything else you don't want a contributor reading and gaming.
  • Public .loopover.yml (repo root, contributor-visible): work-area guidance (wantedPaths), test expectations, and review-panel presentation — nothing here should describe your private enforcement strategy.

Safety

Never commit real policy into this directory or into these example files: no maintainer usernames, no repo names, no thresholds beyond illustrative placeholders, no secrets or tokens. The .loopover.yml-named template files shipped alongside this README (see the catalog in TEMPLATES.md) are deliberately generic and inert — copy one into your own mounted LOOPOVER_REPO_CONFIG_DIR, name the copy .loopover.yml, and edit the copy, not this one.