@loopover/engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/LICENSE +661 -0
- package/README.md +676 -0
- package/dist/advisory/gate-advisory.d.ts +151 -0
- package/dist/advisory/gate-advisory.js +512 -0
- package/dist/ai-policy-map.d.ts +97 -0
- package/dist/ai-policy-map.js +377 -0
- package/dist/ams-policy-spec.d.ts +56 -0
- package/dist/ams-policy-spec.js +165 -0
- package/dist/calibration-dashboard.d.ts +23 -0
- package/dist/calibration-dashboard.js +65 -0
- package/dist/calibration-trend.d.ts +40 -0
- package/dist/calibration-trend.js +60 -0
- package/dist/contributor-fit.d.ts +24 -0
- package/dist/contributor-fit.js +50 -0
- package/dist/discovery-index-contract.d.ts +74 -0
- package/dist/discovery-index-contract.js +184 -0
- package/dist/discovery-soft-claim.d.ts +35 -0
- package/dist/discovery-soft-claim.js +50 -0
- package/dist/duplicate-winner.d.ts +57 -0
- package/dist/duplicate-winner.js +98 -0
- package/dist/feasibility.d.ts +27 -0
- package/dist/feasibility.js +86 -0
- package/dist/finding-severity-calibration.d.ts +116 -0
- package/dist/finding-severity-calibration.js +457 -0
- package/dist/fleet-run-manifest.d.ts +41 -0
- package/dist/fleet-run-manifest.js +151 -0
- package/dist/focus-manifest/guidance.d.ts +21 -0
- package/dist/focus-manifest/guidance.js +222 -0
- package/dist/focus-manifest.d.ts +826 -0
- package/dist/focus-manifest.js +2730 -0
- package/dist/gate-verdict-calibration.d.ts +111 -0
- package/dist/gate-verdict-calibration.js +411 -0
- package/dist/github/constants.d.ts +1 -0
- package/dist/github/constants.js +1 -0
- package/dist/github/sanitize-public-comment.d.ts +1 -0
- package/dist/github/sanitize-public-comment.js +26 -0
- package/dist/goal-model.d.ts +7 -0
- package/dist/goal-model.js +87 -0
- package/dist/governor/action-mode.d.ts +55 -0
- package/dist/governor/action-mode.js +74 -0
- package/dist/governor/budget-cap.d.ts +49 -0
- package/dist/governor/budget-cap.js +34 -0
- package/dist/governor/chokepoint.d.ts +58 -0
- package/dist/governor/chokepoint.js +269 -0
- package/dist/governor/kill-switch.d.ts +38 -0
- package/dist/governor/kill-switch.js +62 -0
- package/dist/governor/rate-limit.d.ts +40 -0
- package/dist/governor/rate-limit.js +63 -0
- package/dist/governor/reputation-throttle.d.ts +50 -0
- package/dist/governor/reputation-throttle.js +94 -0
- package/dist/governor/run-halt.d.ts +39 -0
- package/dist/governor/run-halt.js +83 -0
- package/dist/governor/self-plagiarism.d.ts +47 -0
- package/dist/governor/self-plagiarism.js +200 -0
- package/dist/governor/write-rate-limit.d.ts +52 -0
- package/dist/governor/write-rate-limit.js +132 -0
- package/dist/governor-ledger.d.ts +25 -0
- package/dist/governor-ledger.js +91 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +120 -0
- package/dist/issue-plan-decomposition.d.ts +18 -0
- package/dist/issue-plan-decomposition.js +56 -0
- package/dist/issue-rag-query.d.ts +12 -0
- package/dist/issue-rag-query.js +35 -0
- package/dist/local-scorer.d.ts +34 -0
- package/dist/local-scorer.js +36 -0
- package/dist/metadata-best-min-score.d.ts +9 -0
- package/dist/metadata-best-min-score.js +9 -0
- package/dist/metadata-best-pick.d.ts +9 -0
- package/dist/metadata-best-pick.js +9 -0
- package/dist/metadata-min-score.d.ts +9 -0
- package/dist/metadata-min-score.js +13 -0
- package/dist/metadata-top-min-score.d.ts +9 -0
- package/dist/metadata-top-min-score.js +13 -0
- package/dist/metadata-top-pick.d.ts +9 -0
- package/dist/metadata-top-pick.js +13 -0
- package/dist/miner/acceptance-criteria.d.ts +43 -0
- package/dist/miner/acceptance-criteria.js +77 -0
- package/dist/miner/agent-sdk-driver.d.ts +39 -0
- package/dist/miner/agent-sdk-driver.js +162 -0
- package/dist/miner/attempt-log.d.ts +29 -0
- package/dist/miner/attempt-log.js +100 -0
- package/dist/miner/attempt-metering.d.ts +39 -0
- package/dist/miner/attempt-metering.js +67 -0
- package/dist/miner/cli-subprocess-driver.d.ts +68 -0
- package/dist/miner/cli-subprocess-driver.js +272 -0
- package/dist/miner/coding-agent-driver.d.ts +33 -0
- package/dist/miner/coding-agent-driver.js +36 -0
- package/dist/miner/coding-agent-invoke.d.ts +11 -0
- package/dist/miner/coding-agent-invoke.js +80 -0
- package/dist/miner/coding-agent-mode.d.ts +25 -0
- package/dist/miner/coding-agent-mode.js +43 -0
- package/dist/miner/driver-factory.d.ts +82 -0
- package/dist/miner/driver-factory.js +169 -0
- package/dist/miner/harness-submission-trigger.d.ts +37 -0
- package/dist/miner/harness-submission-trigger.js +46 -0
- package/dist/miner/iterate-loop.d.ts +97 -0
- package/dist/miner/iterate-loop.js +278 -0
- package/dist/miner/iterate-policy.d.ts +98 -0
- package/dist/miner/iterate-policy.js +98 -0
- package/dist/miner/lint-guard.d.ts +50 -0
- package/dist/miner/lint-guard.js +68 -0
- package/dist/miner/local-write-tools.d.ts +81 -0
- package/dist/miner/local-write-tools.js +113 -0
- package/dist/miner/loop-reentry-policy.d.ts +36 -0
- package/dist/miner/loop-reentry-policy.js +53 -0
- package/dist/miner/repo-map.d.ts +51 -0
- package/dist/miner/repo-map.js +268 -0
- package/dist/miner/self-review-adapter.d.ts +106 -0
- package/dist/miner/self-review-adapter.js +83 -0
- package/dist/miner/submission-gate.d.ts +40 -0
- package/dist/miner/submission-gate.js +89 -0
- package/dist/miner/worktree-allocator.d.ts +59 -0
- package/dist/miner/worktree-allocator.js +60 -0
- package/dist/miner/worktree-pool.d.ts +55 -0
- package/dist/miner/worktree-pool.js +65 -0
- package/dist/miner-goal-lane-fit.d.ts +18 -0
- package/dist/miner-goal-lane-fit.js +76 -0
- package/dist/miner-goal-spec.d.ts +139 -0
- package/dist/miner-goal-spec.js +281 -0
- package/dist/miner-prediction-metrics.d.ts +16 -0
- package/dist/miner-prediction-metrics.js +61 -0
- package/dist/miner-telemetry.d.ts +32 -0
- package/dist/miner-telemetry.js +87 -0
- package/dist/objective-anchor.d.ts +125 -0
- package/dist/objective-anchor.js +401 -0
- package/dist/opportunity-competition.d.ts +6 -0
- package/dist/opportunity-competition.js +29 -0
- package/dist/opportunity-freshness.d.ts +18 -0
- package/dist/opportunity-freshness.js +55 -0
- package/dist/opportunity-metadata.d.ts +53 -0
- package/dist/opportunity-metadata.js +204 -0
- package/dist/opportunity-ranker.d.ts +46 -0
- package/dist/opportunity-ranker.js +74 -0
- package/dist/pairwise-calibration.d.ts +49 -0
- package/dist/pairwise-calibration.js +106 -0
- package/dist/phase7-calibration-loop.d.ts +136 -0
- package/dist/phase7-calibration-loop.js +437 -0
- package/dist/plan-blocked.d.ts +6 -0
- package/dist/plan-blocked.js +24 -0
- package/dist/plan-completed.d.ts +5 -0
- package/dist/plan-completed.js +6 -0
- package/dist/plan-completion.d.ts +6 -0
- package/dist/plan-completion.js +7 -0
- package/dist/plan-empty.d.ts +5 -0
- package/dist/plan-empty.js +6 -0
- package/dist/plan-export.d.ts +27 -0
- package/dist/plan-export.js +86 -0
- package/dist/plan-failure.d.ts +5 -0
- package/dist/plan-failure.js +6 -0
- package/dist/plan-overall-status.d.ts +6 -0
- package/dist/plan-overall-status.js +25 -0
- package/dist/plan-pending.d.ts +5 -0
- package/dist/plan-pending.js +6 -0
- package/dist/plan-progress-complete.d.ts +7 -0
- package/dist/plan-progress-complete.js +10 -0
- package/dist/plan-ready.d.ts +6 -0
- package/dist/plan-ready.js +12 -0
- package/dist/plan-running.d.ts +5 -0
- package/dist/plan-running.js +6 -0
- package/dist/plan-skipped.d.ts +5 -0
- package/dist/plan-skipped.js +6 -0
- package/dist/plan-step-count.d.ts +5 -0
- package/dist/plan-step-count.js +6 -0
- package/dist/plan-step-stats.d.ts +5 -0
- package/dist/plan-step-stats.js +6 -0
- package/dist/plan-templates.d.ts +25 -0
- package/dist/plan-templates.js +107 -0
- package/dist/plan-terminated.d.ts +6 -0
- package/dist/plan-terminated.js +9 -0
- package/dist/portfolio/non-convergence.d.ts +37 -0
- package/dist/portfolio/non-convergence.js +57 -0
- package/dist/portfolio/queue.d.ts +30 -0
- package/dist/portfolio/queue.js +150 -0
- package/dist/predicted-gate.d.ts +103 -0
- package/dist/predicted-gate.js +218 -0
- package/dist/prompt-packet.d.ts +11 -0
- package/dist/prompt-packet.js +34 -0
- package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-best-min-score.js +9 -0
- package/dist/ranked-opportunity-best-pick.d.ts +8 -0
- package/dist/ranked-opportunity-best-pick.js +9 -0
- package/dist/ranked-opportunity-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-min-score.js +13 -0
- package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-top-min-score.js +13 -0
- package/dist/review/advisory-ai-routing-config.d.ts +8 -0
- package/dist/review/advisory-ai-routing-config.js +40 -0
- package/dist/review/check-names.d.ts +1 -0
- package/dist/review/check-names.js +1 -0
- package/dist/review/cla-check.d.ts +49 -0
- package/dist/review/cla-check.js +71 -0
- package/dist/review/diff-file-priority.d.ts +1 -0
- package/dist/review/diff-file-priority.js +12 -0
- package/dist/review/enrichment-analyzer-names.d.ts +3 -0
- package/dist/review/enrichment-analyzer-names.js +63 -0
- package/dist/review/guardrail-config.d.ts +10 -0
- package/dist/review/guardrail-config.js +62 -0
- package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
- package/dist/review/linked-issue-hard-rules-config.js +79 -0
- package/dist/review/linked-issue-label-propagation.d.ts +9 -0
- package/dist/review/linked-issue-label-propagation.js +114 -0
- package/dist/review/pre-merge-checks.d.ts +28 -0
- package/dist/review/pre-merge-checks.js +66 -0
- package/dist/review/review-thread-findings.d.ts +1 -0
- package/dist/review/review-thread-findings.js +1 -0
- package/dist/review/safe-url.d.ts +5 -0
- package/dist/review/safe-url.js +137 -0
- package/dist/review/screenshot-table-gate.d.ts +87 -0
- package/dist/review/screenshot-table-gate.js +340 -0
- package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
- package/dist/review/unlinked-issue-guardrail-config.js +44 -0
- package/dist/reviewer-consensus-calibration.d.ts +113 -0
- package/dist/reviewer-consensus-calibration.js +493 -0
- package/dist/reward-risk.d.ts +223 -0
- package/dist/reward-risk.js +618 -0
- package/dist/scoring/label-match.d.ts +4 -0
- package/dist/scoring/label-match.js +145 -0
- package/dist/scoring/model.d.ts +31 -0
- package/dist/scoring/model.js +157 -0
- package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
- package/dist/scoring/pending-pr-scenarios.js +145 -0
- package/dist/scoring/preview.d.ts +246 -0
- package/dist/scoring/preview.js +1108 -0
- package/dist/scoring/types.d.ts +131 -0
- package/dist/scoring/types.js +7 -0
- package/dist/settings/auto-close-exempt.d.ts +10 -0
- package/dist/settings/auto-close-exempt.js +58 -0
- package/dist/settings/autonomy.d.ts +33 -0
- package/dist/settings/autonomy.js +80 -0
- package/dist/settings/command-authorization.d.ts +49 -0
- package/dist/settings/command-authorization.js +222 -0
- package/dist/settings/contributor-blacklist.d.ts +17 -0
- package/dist/settings/contributor-blacklist.js +86 -0
- package/dist/settings/moderation-rules.d.ts +51 -0
- package/dist/settings/moderation-rules.js +104 -0
- package/dist/settings/pr-type-label.d.ts +70 -0
- package/dist/settings/pr-type-label.js +149 -0
- package/dist/signals/change-guardrail.d.ts +49 -0
- package/dist/signals/change-guardrail.js +164 -0
- package/dist/signals/check-summary.d.ts +9 -0
- package/dist/signals/check-summary.js +12 -0
- package/dist/signals/contributor-calibration.d.ts +23 -0
- package/dist/signals/contributor-calibration.js +47 -0
- package/dist/signals/duplicate-winner.d.ts +9 -0
- package/dist/signals/duplicate-winner.js +9 -0
- package/dist/signals/path-matchers.d.ts +36 -0
- package/dist/signals/path-matchers.js +371 -0
- package/dist/signals/predicted-gate-engine.d.ts +62 -0
- package/dist/signals/predicted-gate-engine.js +895 -0
- package/dist/signals/preflight-limits.d.ts +14 -0
- package/dist/signals/preflight-limits.js +14 -0
- package/dist/signals/slop.d.ts +56 -0
- package/dist/signals/slop.js +324 -0
- package/dist/signals/test-evidence.d.ts +25 -0
- package/dist/signals/test-evidence.js +108 -0
- package/dist/subprocess-env.d.ts +23 -0
- package/dist/subprocess-env.js +74 -0
- package/dist/track-record-summary.d.ts +92 -0
- package/dist/track-record-summary.js +301 -0
- package/dist/types/manifest-deps-types.d.ts +514 -0
- package/dist/types/manifest-deps-types.js +4 -0
- package/dist/types/predicted-gate-types.d.ts +338 -0
- package/dist/types/predicted-gate-types.js +3 -0
- package/dist/types/reward-risk-types.d.ts +319 -0
- package/dist/types/reward-risk-types.js +11 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +80 -0
|
@@ -0,0 +1,2730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus-manifest parse/compile core (#2280). Extracted to `@loopover/engine` so the maintainer
|
|
3
|
+
* review stack and the miner's goal-spec parser share identical, versioned manifest logic instead of drifting
|
|
4
|
+
* apart. This is the MINER-side parse-pattern template for {@link MinerGoalSpec} (`.gittensory-miner.yml`) —
|
|
5
|
+
* same tolerant-parser shape: typed config + safe defaults + warnings, never throws.
|
|
6
|
+
*
|
|
7
|
+
* App-local resolver/guidance functions (`resolveEffectiveSettings`, `buildFocusManifestGuidance`, etc.) remain
|
|
8
|
+
* in `src/signals/focus-manifest.ts` as a shim over this module.
|
|
9
|
+
*/
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "./settings/autonomy.js";
|
|
12
|
+
import { normalizeCommandAuthorizationPolicy } from "./settings/command-authorization.js";
|
|
13
|
+
import { normalizeContributorBlacklist } from "./settings/contributor-blacklist.js";
|
|
14
|
+
import { normalizeAutoCloseExemptLogins } from "./settings/auto-close-exempt.js";
|
|
15
|
+
import { MAX_TYPE_LABEL_NAME_LENGTH, normalizeTypeLabelSet } from "./settings/pr-type-label.js";
|
|
16
|
+
import { normalizeLinkedIssueLabelPropagationConfig, VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES, } from "./review/linked-issue-label-propagation.js";
|
|
17
|
+
import { isLinkedIssueHardRuleMode, normalizeLinkedIssueHardRulesConfig, } from "./review/linked-issue-hard-rules-config.js";
|
|
18
|
+
import { isUnlinkedIssueGuardrailMode, normalizeUnlinkedIssueGuardrailConfig, } from "./review/unlinked-issue-guardrail-config.js";
|
|
19
|
+
import { normalizeAdvisoryAiRoutingConfig } from "./review/advisory-ai-routing-config.js";
|
|
20
|
+
import { isScreenshotTableGateAction, normalizeScreenshotTableGateConfig, } from "./review/screenshot-table-gate.js";
|
|
21
|
+
import { normalizeModerationLabel, normalizeModerationRules } from "./settings/moderation-rules.js";
|
|
22
|
+
import { REES_ANALYZER_NAME_SET } from "./review/enrichment-analyzer-names.js";
|
|
23
|
+
import { hasUnsafeWildcardCount } from "./signals/change-guardrail.js";
|
|
24
|
+
import { isSafeHttpUrl } from "./review/safe-url.js";
|
|
25
|
+
/** Canonical local-filesystem-root vocabulary for public-safety filtering (from `src/signals/redaction.ts`). */
|
|
26
|
+
const PUBLIC_LOCAL_PATH_INLINE = String.raw `/Users/|/home/|/root/|/var/|/opt/|/tmp/|/private/|[A-Za-z]:[\\/]Users[\\/]|[A-Za-z]:[\\/]Program Files[\\/]`;
|
|
27
|
+
// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private
|
|
28
|
+
// `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master
|
|
29
|
+
// kill-switch (the feature never runs when its env flag is off, regardless of this block). See
|
|
30
|
+
// review/feature-activation.ts for the resolver (env kill-switch → per-repo override → env-allowlist default).
|
|
31
|
+
// NOTE: only the per-PR REVIEW features whose every activation site is migrated are listed here. grounding
|
|
32
|
+
// (#4100) is now migrated too — its original "coupled to the merge/close DISPOSITION path" blocker was the
|
|
33
|
+
// removed AI CI-refutation path (grounding-wire.ts's aiCiRefutationActive is now a vestigial historical-
|
|
34
|
+
// compatibility helper with zero real callers); grounding today only shapes reviewer PROMPT content, same
|
|
35
|
+
// shape as rag/reputation. contentLane got its own richer `contentLane:` block below (#2435) instead of a
|
|
36
|
+
// boolean here, since it resolves to a whole RegistryLaneSpec, not an on/off toggle — see
|
|
37
|
+
// resolveRegistryLaneSpec in review/content-lane/spec-resolver.ts (its own precedence already matches this
|
|
38
|
+
// block's env-kill-switch → override → allowlist-default shape one-for-one; it just isn't literally routed
|
|
39
|
+
// through resolveConvergedFeature yet — a disclosed, low-priority fast-follow, #4616). `selftune` (#4104)
|
|
40
|
+
// ALSO deliberately lives outside this block, as its own top-level `review.selftune` field below — it has no
|
|
41
|
+
// `GITTENSORY_REVIEW_REPOS` allowlist to fall back to (its own repo scoping is `isAgentConfigured`, a
|
|
42
|
+
// different consent boundary), so it doesn't fit this resolver's env-kill-switch → override → allowlist-
|
|
43
|
+
// default shape; see `selfTuneRepos` in `review/selftune-wire.ts`. `e2eTests` (#4190, part of the #4189
|
|
44
|
+
// E2E-test-generation epic) fits this shape exactly as a plain symmetric override — unlike `safety`/
|
|
45
|
+
// `grounding` it has no force-on-only or force-off-only floor/ceiling, since AI-generated test content
|
|
46
|
+
// carries no security-hardening or full-file-fetch rationale to protect from a repo-controlled override.
|
|
47
|
+
// `screenshots` (#4616) joined this block for the SAME reason `e2eTests` fits it plainly: capturing a
|
|
48
|
+
// before/after render of the PR's own web-visible files carries no security-hardening or full-file-fetch
|
|
49
|
+
// rationale either, so it gets the standard override, not an asymmetric one. Before #4616 it had NO
|
|
50
|
+
// `features:` override at all (env flag AND allowlist only) despite being documented right next to its six
|
|
51
|
+
// siblings in `.gittensory.yml.example` — a self-hoster who guessed `features.screenshots: true` (a natural
|
|
52
|
+
// guess given the sibling keys) found it silently did nothing. `features.screenshots` is layered UNDER the
|
|
53
|
+
// separate, richer `review.visual.*` block (route/preview-URL config, #3609/#3610, and `review.visual.enabled:
|
|
54
|
+
// false` as an always-available additional force-off, #4083) — that block still narrows/disables capture
|
|
55
|
+
// AFTER this key decides whether capture is even attempted for the repo at all; the two are independent and
|
|
56
|
+
// `review.visual.enabled` keeps its own existing force-off-only semantics untouched by this change.
|
|
57
|
+
// `improvementSignal` (#4738, foundation phase of the #4737 PR-improvement-signal epic) is likewise a plain
|
|
58
|
+
// symmetric override: it is a READ-ONLY advisory quality-delta signal, not a security control, so a repo-
|
|
59
|
+
// level `false` behaves like any other plain override with no floor/ceiling. This is activation wiring only
|
|
60
|
+
// -- no tier reads the resolved value yet (sibling sub-issues #4739-#4746 build the deterministic/LLM/panel
|
|
61
|
+
// behavior that will gate on it).
|
|
62
|
+
export const CONVERGED_FEATURE_KEYS = ["rag", "reputation", "unifiedComment", "safety", "grounding", "e2eTests", "screenshots", "improvementSignal"];
|
|
63
|
+
/** Optional ecosystem/network integrations under the `experimental:` block — plugins that couple gittensory to
|
|
64
|
+
* an external system rather than core review behavior. Starts with `gittensor` (the subnet mining-registry/
|
|
65
|
+
* scoring integration gittensory originally shipped with); future plugins land in this same array as the
|
|
66
|
+
* product broadens beyond gittensor. Deliberately a SEPARATE block from `features:` (converged review
|
|
67
|
+
* capabilities) — an operator (especially self-host) should be able to see at a glance which toggles are "how
|
|
68
|
+
* gittensory reviews PRs" vs "which external network/ecosystem this instance opts into." */
|
|
69
|
+
export const EXPERIMENTAL_PLUGIN_KEYS = ["gittensor"];
|
|
70
|
+
/** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. `improvementSignal`
|
|
71
|
+
* (#4744) is the newest: the optional row combining the deterministic structural-improvement tier (#4742) and,
|
|
72
|
+
* where also active, the LLM tier's composed value judgment (#4743) -- default-shown, like every sibling here,
|
|
73
|
+
* when the `improvementSignal` converged feature itself is active for the repo. */
|
|
74
|
+
export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult", "improvementSignal"];
|
|
75
|
+
// `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking
|
|
76
|
+
// defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits;
|
|
77
|
+
// `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER
|
|
78
|
+
// changes the gate verdict, only how much advisory detail the review write-up carries.
|
|
79
|
+
export const REVIEW_PROFILES = ["chill", "balanced", "assertive"];
|
|
80
|
+
export const REVIEW_FINDING_SEVERITY_LADDER = ["critical", "major", "minor", "nitpick"];
|
|
81
|
+
/** `review.linkedIssueSatisfaction` modes (#2173). `off` = not evaluated (same as unset). */
|
|
82
|
+
export const LINKED_ISSUE_SATISFACTION_MODES = ["off", "advisory", "block"];
|
|
83
|
+
/** `review.comment_verbosity` levels (#2047). `normal` = today's behavior (same as unset). */
|
|
84
|
+
export const COMMENT_VERBOSITY_LEVELS = ["quiet", "normal", "detailed"];
|
|
85
|
+
/** `review.e2e_test_delivery` modes (#4197). `comment` = today's behavior (same as unset). */
|
|
86
|
+
export const E2E_TEST_DELIVERY_MODES = ["comment", "commit"];
|
|
87
|
+
/** `review.auto_review.cadence` (#one-shot-review-cadence). `one_shot` = the AI-generated content (main review,
|
|
88
|
+
* slop advisory, linked-issue satisfaction) is produced once per PR and never automatically regenerated
|
|
89
|
+
* afterward — not on a new push, not on CI-check completion, not on a scheduled sweep tick; only an explicit
|
|
90
|
+
* maintainer retrigger (the PR-panel checkbox or `@gittensory review` as a maintainer) spends a fresh call.
|
|
91
|
+
* `continuous` = the traditional behavior — every trigger re-runs AI content generation, subject to each
|
|
92
|
+
* feature's own head-SHA cache. Orthogonal to `aiReviewMode`'s enforcement-strictness axis (off/advisory/
|
|
93
|
+
* block) — the deterministic gate (CI status, mergeability, static-rule blockers) is NEVER affected by this
|
|
94
|
+
* and always re-evaluates on every pass regardless of cadence. */
|
|
95
|
+
export const AI_REVIEW_CADENCES = ["one_shot", "continuous"];
|
|
96
|
+
export const EMPTY_MAX_FINDINGS_CONFIG = { blockers: null, nits: null };
|
|
97
|
+
export const EMPTY_AUTO_REVIEW_CONFIG = {
|
|
98
|
+
skipDrafts: null,
|
|
99
|
+
cadence: null,
|
|
100
|
+
ignoreAuthors: [],
|
|
101
|
+
ignoreTitleKeywords: [],
|
|
102
|
+
skipLabels: [],
|
|
103
|
+
skipDocsOnly: null,
|
|
104
|
+
maxAddedLines: 0,
|
|
105
|
+
maxFiles: 0,
|
|
106
|
+
baseBranches: [],
|
|
107
|
+
autoPauseAfterReviewedCommits: null,
|
|
108
|
+
};
|
|
109
|
+
export const EMPTY_SELF_HOST_AI_MODEL_CONFIG = {
|
|
110
|
+
claudeModel: null,
|
|
111
|
+
claudeEffort: null,
|
|
112
|
+
codexModel: null,
|
|
113
|
+
codexEffort: null,
|
|
114
|
+
ollamaModel: null,
|
|
115
|
+
openaiModel: null,
|
|
116
|
+
openaiCompatibleModel: null,
|
|
117
|
+
anthropicModel: null,
|
|
118
|
+
};
|
|
119
|
+
export const EMPTY_VISUAL_CONFIG = {
|
|
120
|
+
productionUrl: null,
|
|
121
|
+
preview: { urlTemplate: null },
|
|
122
|
+
routes: { paths: [], maxRoutes: null },
|
|
123
|
+
themes: [],
|
|
124
|
+
gif: false,
|
|
125
|
+
enabled: null,
|
|
126
|
+
themeStorageKey: null,
|
|
127
|
+
actionsFallback: false,
|
|
128
|
+
};
|
|
129
|
+
// A hard cap so a hostile/huge manifest can't bloat the reviewer prompt (mirrors REVIEW_FIELD_KEYS discipline).
|
|
130
|
+
const MAX_PATH_INSTRUCTIONS = 50;
|
|
131
|
+
const MAX_LIST_ITEMS = 200;
|
|
132
|
+
const MAX_ITEM_LENGTH = 300;
|
|
133
|
+
const MAX_GLOBSTAR_SLASH_ALTERNATIVES = 128;
|
|
134
|
+
// 128 KiB, not 64 KiB: gittensory.full.yml (our own reference doc, parsed by config-templates.test.ts as a
|
|
135
|
+
// round-trip check) organically grows every time a new review.* knob ships and had already reached 65522/65536
|
|
136
|
+
// bytes on main before this comment was written -- one doc line from any PR would trip the old ceiling. A real
|
|
137
|
+
// per-repo .gittensory.yml never needs anywhere near this size, so the DoS-guard intent is unaffected (#2006).
|
|
138
|
+
export const MAX_FOCUS_MANIFEST_BYTES = 128 * 1024;
|
|
139
|
+
const EMPTY_GATE_CONFIG = {
|
|
140
|
+
present: false,
|
|
141
|
+
enabled: null,
|
|
142
|
+
checkMode: null,
|
|
143
|
+
pack: null,
|
|
144
|
+
linkedIssue: null,
|
|
145
|
+
duplicates: null,
|
|
146
|
+
readinessMode: null,
|
|
147
|
+
readinessMinScore: null,
|
|
148
|
+
slopMode: null,
|
|
149
|
+
slopMinScore: null,
|
|
150
|
+
slopAiAdvisory: null,
|
|
151
|
+
sizeMode: null,
|
|
152
|
+
lockfileIntegrityMode: null,
|
|
153
|
+
aiReviewMode: null,
|
|
154
|
+
aiReviewByok: null,
|
|
155
|
+
aiReviewProvider: null,
|
|
156
|
+
aiReviewModel: null,
|
|
157
|
+
aiReviewAllAuthors: null,
|
|
158
|
+
aiReviewCloseConfidence: null,
|
|
159
|
+
aiReviewLowConfidenceDisposition: null,
|
|
160
|
+
aiReviewCombine: null,
|
|
161
|
+
aiReviewOnMerge: null,
|
|
162
|
+
aiReviewReviewers: null,
|
|
163
|
+
mergeReadiness: null,
|
|
164
|
+
manifestPolicy: null,
|
|
165
|
+
selfAuthoredLinkedIssue: null,
|
|
166
|
+
linkedIssueSatisfaction: null,
|
|
167
|
+
dryRun: null,
|
|
168
|
+
firstTimeContributorGrace: null,
|
|
169
|
+
premergeContentRecheck: null,
|
|
170
|
+
requireFreshRebaseWindowMinutes: null,
|
|
171
|
+
claMode: null,
|
|
172
|
+
claConsentPhrase: null,
|
|
173
|
+
claCheckRunName: null,
|
|
174
|
+
claCheckRunAppSlug: null,
|
|
175
|
+
expectedCiContexts: null,
|
|
176
|
+
aiJudgmentBlockersMode: null,
|
|
177
|
+
copycatMode: null,
|
|
178
|
+
copycatMinScore: null,
|
|
179
|
+
};
|
|
180
|
+
const EMPTY_FEATURES_CONFIG = {
|
|
181
|
+
present: false,
|
|
182
|
+
rag: null,
|
|
183
|
+
reputation: null,
|
|
184
|
+
unifiedComment: null,
|
|
185
|
+
safety: null,
|
|
186
|
+
grounding: null,
|
|
187
|
+
e2eTests: null,
|
|
188
|
+
screenshots: null,
|
|
189
|
+
improvementSignal: null,
|
|
190
|
+
};
|
|
191
|
+
const EMPTY_EXPERIMENTAL_CONFIG = {
|
|
192
|
+
present: false,
|
|
193
|
+
gittensor: null,
|
|
194
|
+
};
|
|
195
|
+
const EMPTY_CONTENT_LANE_CONFIG = {
|
|
196
|
+
present: false,
|
|
197
|
+
entryFileGlob: null,
|
|
198
|
+
providerFileGlob: null,
|
|
199
|
+
artifactGlob: null,
|
|
200
|
+
collectionField: null,
|
|
201
|
+
maxAppendedEntries: null,
|
|
202
|
+
duplicateKeyFields: [],
|
|
203
|
+
validatorId: null,
|
|
204
|
+
};
|
|
205
|
+
const DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS = 7;
|
|
206
|
+
const EMPTY_REPO_DOC_GENERATION_CONFIG = {
|
|
207
|
+
present: false,
|
|
208
|
+
enabled: false,
|
|
209
|
+
scope: ["agents"],
|
|
210
|
+
allowOverwriteExisting: false,
|
|
211
|
+
refreshIntervalDays: DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS,
|
|
212
|
+
};
|
|
213
|
+
const DEFAULT_REVIEW_RECAP_CADENCE_DAYS = 7;
|
|
214
|
+
const EMPTY_REVIEW_RECAP_CONFIG = {
|
|
215
|
+
present: false,
|
|
216
|
+
enabled: false,
|
|
217
|
+
cadenceDays: DEFAULT_REVIEW_RECAP_CADENCE_DAYS,
|
|
218
|
+
};
|
|
219
|
+
const DEFAULT_MAINTAINER_RECAP_CADENCE = "weekly";
|
|
220
|
+
const DEFAULT_MAINTAINER_RECAP_CHANNEL = "discord";
|
|
221
|
+
const EMPTY_MAINTAINER_RECAP_CONFIG = {
|
|
222
|
+
present: false,
|
|
223
|
+
enabled: false,
|
|
224
|
+
cadence: DEFAULT_MAINTAINER_RECAP_CADENCE,
|
|
225
|
+
channel: DEFAULT_MAINTAINER_RECAP_CHANNEL,
|
|
226
|
+
};
|
|
227
|
+
const EMPTY_MANIFEST = {
|
|
228
|
+
present: false,
|
|
229
|
+
source: "none",
|
|
230
|
+
wantedPaths: [],
|
|
231
|
+
preferredLabels: [],
|
|
232
|
+
linkedIssuePolicy: "optional",
|
|
233
|
+
testExpectations: [],
|
|
234
|
+
issueDiscoveryPolicy: "neutral",
|
|
235
|
+
maintainerNotes: [],
|
|
236
|
+
publicNotes: [],
|
|
237
|
+
gate: { ...EMPTY_GATE_CONFIG },
|
|
238
|
+
settings: {},
|
|
239
|
+
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null },
|
|
240
|
+
features: { ...EMPTY_FEATURES_CONFIG },
|
|
241
|
+
experimental: { ...EMPTY_EXPERIMENTAL_CONFIG },
|
|
242
|
+
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
|
|
243
|
+
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
|
|
244
|
+
reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG },
|
|
245
|
+
maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG },
|
|
246
|
+
warnings: [],
|
|
247
|
+
};
|
|
248
|
+
// This surface's economic/identity term vocabulary is intentionally richer than the canonical
|
|
249
|
+
// PUBLIC_UNSAFE_TERMS (extra phrases like "public score estimate"), so it stays a local literal. The local
|
|
250
|
+
// filesystem paths, however, compose from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (which also
|
|
251
|
+
// covers `/var/`, previously missed here, plus `/root/` and the forward-slash Windows form `C:/Users/`) so this
|
|
252
|
+
// guard cannot drift from the canonical boundary on a leaking root.
|
|
253
|
+
const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i;
|
|
254
|
+
const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(PUBLIC_LOCAL_PATH_INLINE, "i");
|
|
255
|
+
/**
|
|
256
|
+
* Public-safe redaction guard shared with the local-branch packet renderer. Public manifest
|
|
257
|
+
* text must not leak reward, wallet/key, ranking, or local filesystem path material.
|
|
258
|
+
*/
|
|
259
|
+
export function isFocusManifestPublicSafe(text) {
|
|
260
|
+
return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text);
|
|
261
|
+
}
|
|
262
|
+
function emptyManifest(source, warnings = []) {
|
|
263
|
+
return {
|
|
264
|
+
...EMPTY_MANIFEST,
|
|
265
|
+
source,
|
|
266
|
+
warnings,
|
|
267
|
+
gate: { ...EMPTY_GATE_CONFIG },
|
|
268
|
+
settings: {},
|
|
269
|
+
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null },
|
|
270
|
+
features: { ...EMPTY_FEATURES_CONFIG },
|
|
271
|
+
experimental: { ...EMPTY_EXPERIMENTAL_CONFIG },
|
|
272
|
+
contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
|
|
273
|
+
repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
|
|
274
|
+
reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG },
|
|
275
|
+
maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function normalizeStringList(value, field, warnings) {
|
|
279
|
+
if (value === undefined || value === null)
|
|
280
|
+
return [];
|
|
281
|
+
if (!Array.isArray(value)) {
|
|
282
|
+
warnings.push(`Manifest field "${field}" must be a list; ignoring a ${typeof value} value.`);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const result = [];
|
|
286
|
+
for (const entry of value) {
|
|
287
|
+
if (typeof entry !== "string") {
|
|
288
|
+
warnings.push(`Manifest field "${field}" skipped a non-string entry.`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const trimmed = entry.trim();
|
|
292
|
+
if (!trimmed)
|
|
293
|
+
continue;
|
|
294
|
+
// Truncate in place, then flow through the same de-dup and cap logic. Falling through (rather than
|
|
295
|
+
// `continue`-ing) keeps over-long entries subject to both limits, so untrusted manifests cannot
|
|
296
|
+
// bypass de-duplication or the MAX_LIST_ITEMS safety cap via pathological long entries.
|
|
297
|
+
let normalized = trimmed;
|
|
298
|
+
if (normalized.length > MAX_ITEM_LENGTH) {
|
|
299
|
+
warnings.push(`Manifest field "${field}" truncated an over-long entry.`);
|
|
300
|
+
normalized = normalized.slice(0, MAX_ITEM_LENGTH);
|
|
301
|
+
}
|
|
302
|
+
if (!result.includes(normalized))
|
|
303
|
+
result.push(normalized);
|
|
304
|
+
if (result.length >= MAX_LIST_ITEMS) {
|
|
305
|
+
warnings.push(`Manifest field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
/** Like {@link normalizeStringList}, but returns `null` (not `[]`) when unset or when nothing survives
|
|
312
|
+
* validation — the convention every OTHER `FocusManifestGateConfig` field uses for "not configured", so
|
|
313
|
+
* the resolver's `!== null` overlay checks work uniformly. */
|
|
314
|
+
function normalizeOptionalStringList(value, field, warnings) {
|
|
315
|
+
if (value === undefined || value === null)
|
|
316
|
+
return null;
|
|
317
|
+
const list = normalizeStringList(value, field, warnings);
|
|
318
|
+
return list.length > 0 ? list : null;
|
|
319
|
+
}
|
|
320
|
+
function normalizeEnum(value, field, allowed, fallback, warnings) {
|
|
321
|
+
if (value === undefined || value === null)
|
|
322
|
+
return fallback;
|
|
323
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
324
|
+
warnings.push(`Manifest field "${field}" must be one of ${allowed.join(", ")}; falling back to "${fallback}".`);
|
|
325
|
+
return fallback;
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
function normalizeSource(raw, value, warnings) {
|
|
330
|
+
if (raw)
|
|
331
|
+
return raw;
|
|
332
|
+
return normalizeEnum(value, "source", ["repo_file", "api_record", "none"], "api_record", warnings);
|
|
333
|
+
}
|
|
334
|
+
function normalizeOptionalGateMode(value, field, warnings) {
|
|
335
|
+
if (value === undefined || value === null)
|
|
336
|
+
return null;
|
|
337
|
+
if (typeof value === "string") {
|
|
338
|
+
const normalized = value.trim().toLowerCase();
|
|
339
|
+
if (normalized === "off" || normalized === "advisory" || normalized === "block")
|
|
340
|
+
return normalized;
|
|
341
|
+
}
|
|
342
|
+
warnings.push(`Manifest gate field "${field}" must be one of off, advisory, block; ignoring "${String(value)}".`);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
/** `gate.readiness.mode` (and its `settings.qualityGateMode` alias below) is documented and parsed as the shared
|
|
346
|
+
* off/advisory/block tri-state, but buildQualityGateWarning (src/rules/advisory.ts) always produces a
|
|
347
|
+
* warning-severity finding — never a blocker — and isConfiguredGateBlocker has no branch for it: readiness/
|
|
348
|
+
* quality is intentionally informational-only and can never hard-block a PR. Without this, a maintainer who
|
|
349
|
+
* sets `mode: block` believes a real quality floor is enforced when the effective behavior is silently
|
|
350
|
+
* advisory-only (#2267). Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed
|
|
351
|
+
* config always matches what the gate actually does. Exported so the settings-write API routes (the
|
|
352
|
+
* dashboard/API path for the SAME `qualityGateMode` field) can apply the identical downgrade before persisting. */
|
|
353
|
+
export function normalizeReadinessGateMode(value, field, warnings) {
|
|
354
|
+
const mode = normalizeOptionalGateMode(value, field, warnings);
|
|
355
|
+
if (mode !== "block")
|
|
356
|
+
return mode;
|
|
357
|
+
warnings.push(`Manifest gate field "${field}" no longer accepts "block" — readiness/quality is informational-only and can never hard-block a PR; downgrading to "advisory". Use gate.manifestPolicy or another enforceable gate for a real quality floor.`);
|
|
358
|
+
return "advisory";
|
|
359
|
+
}
|
|
360
|
+
function normalizeOptionalBoolean(value, field, warnings) {
|
|
361
|
+
if (value === undefined || value === null)
|
|
362
|
+
return null;
|
|
363
|
+
if (typeof value === "boolean")
|
|
364
|
+
return value;
|
|
365
|
+
warnings.push(`Manifest gate field "${field}" must be a boolean; ignoring a ${typeof value} value.`);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
function normalizeOptionalScore(value, field, warnings) {
|
|
369
|
+
if (value === undefined || value === null)
|
|
370
|
+
return null;
|
|
371
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
372
|
+
warnings.push(`Manifest gate field "${field}" must be a number between 0 and 100; ignoring it.`);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
376
|
+
}
|
|
377
|
+
function normalizeOptionalNonNegativeInt(value, field, warnings) {
|
|
378
|
+
if (value === undefined || value === null)
|
|
379
|
+
return null;
|
|
380
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
381
|
+
warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
/** Parse auto-review size caps where 0 means disabled (byte-identical default). (#2065) */
|
|
387
|
+
function normalizeAutoReviewSizeCap(value, field, warnings) {
|
|
388
|
+
if (value === undefined || value === null)
|
|
389
|
+
return 0;
|
|
390
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
391
|
+
warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
/** Normalize an optional confidence threshold in [0,1] (#7) — a fractional value (NOT a 0-100 score), so it is
|
|
397
|
+
* clamped into range WITHOUT rounding. Absent/null ⇒ null (the resolver leaves the gate's 0.93 default in place);
|
|
398
|
+
* a non-finite/non-number value is ignored with a warning. */
|
|
399
|
+
function normalizeOptionalConfidence(value, field, warnings) {
|
|
400
|
+
if (value === undefined || value === null)
|
|
401
|
+
return null;
|
|
402
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
403
|
+
warnings.push(`Manifest gate field "${field}" must be a number between 0 and 1; ignoring it.`);
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
return Math.max(0, Math.min(1, value));
|
|
407
|
+
}
|
|
408
|
+
// A hard cap on `gate.aiReview.reviewers` entries — the combiner only ever addresses reviewer[0]/[1] (single runs
|
|
409
|
+
// one, consensus/synthesis run two), so anything beyond 2 is inert; capping at 4 leaves headroom without letting a
|
|
410
|
+
// hostile/huge manifest bloat the parsed config for no functional gain.
|
|
411
|
+
const MAX_AI_REVIEW_REVIEWERS = 4;
|
|
412
|
+
/** Normalize `gate.aiReview.reviewers` (#2567) — a list of `{ model, fallback? }` entries naming self-host
|
|
413
|
+
* providers (e.g. `claude-code`, `codex`) to run in place of the operator's `AI_REVIEW_PLAN.reviewers`. Each
|
|
414
|
+
* entry needs a non-empty string `model`; `fallback` is optional and, when present, must also be a non-empty
|
|
415
|
+
* string. Invalid entries are dropped with a warning rather than failing the whole list, mirroring the other
|
|
416
|
+
* manifest list parsers. Absent/empty/all-invalid ⇒ null (so the resolver's `??` fallback to the operator's
|
|
417
|
+
* plan is untouched). */
|
|
418
|
+
function normalizeOptionalReviewers(value, field, warnings) {
|
|
419
|
+
if (value === undefined || value === null)
|
|
420
|
+
return null;
|
|
421
|
+
if (!Array.isArray(value)) {
|
|
422
|
+
warnings.push(`Manifest gate field "${field}" must be a list of { model, fallback? }; ignoring it.`);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const out = [];
|
|
426
|
+
for (const [index, entry] of value.entries()) {
|
|
427
|
+
if (out.length >= MAX_AI_REVIEW_REVIEWERS) {
|
|
428
|
+
warnings.push(`Manifest gate field "${field}" is capped at ${MAX_AI_REVIEW_REVIEWERS} entries; dropping the rest.`);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
432
|
+
warnings.push(`Manifest gate field "${field}[${index}]" must be a mapping with a "model" string; ignoring it.`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const e = entry;
|
|
436
|
+
const model = typeof e.model === "string" ? e.model.trim() : "";
|
|
437
|
+
if (!model) {
|
|
438
|
+
warnings.push(`Manifest gate field "${field}[${index}].model" must be a non-empty string; ignoring the entry.`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const fallback = typeof e.fallback === "string" && e.fallback.trim() ? e.fallback.trim() : undefined;
|
|
442
|
+
out.push(fallback ? { model, fallback } : { model });
|
|
443
|
+
}
|
|
444
|
+
return out.length > 0 ? out : null;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer
|
|
448
|
+
* this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted.
|
|
449
|
+
*/
|
|
450
|
+
function parseGateConfig(value, warnings) {
|
|
451
|
+
if (value === undefined || value === null)
|
|
452
|
+
return { ...EMPTY_GATE_CONFIG };
|
|
453
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
454
|
+
warnings.push(`Manifest field "gate" must be a mapping; ignoring it.`);
|
|
455
|
+
return { ...EMPTY_GATE_CONFIG };
|
|
456
|
+
}
|
|
457
|
+
const record = value;
|
|
458
|
+
const readiness = record.readiness;
|
|
459
|
+
const readinessRecord = readiness !== null && typeof readiness === "object" && !Array.isArray(readiness) ? readiness : undefined;
|
|
460
|
+
if (readiness !== undefined && readiness !== null && readinessRecord === undefined) {
|
|
461
|
+
warnings.push(`Manifest gate field "gate.readiness" must be a mapping; ignoring it.`);
|
|
462
|
+
}
|
|
463
|
+
const aiReview = record.aiReview;
|
|
464
|
+
const aiReviewRecord = aiReview !== null && typeof aiReview === "object" && !Array.isArray(aiReview) ? aiReview : undefined;
|
|
465
|
+
if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) {
|
|
466
|
+
warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`);
|
|
467
|
+
}
|
|
468
|
+
const slop = record.slop;
|
|
469
|
+
const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? slop : undefined;
|
|
470
|
+
if (slop !== undefined && slop !== null && slopRecord === undefined) {
|
|
471
|
+
warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`);
|
|
472
|
+
}
|
|
473
|
+
const copycat = record.copycat;
|
|
474
|
+
const copycatRecord = copycat !== null && typeof copycat === "object" && !Array.isArray(copycat) ? copycat : undefined;
|
|
475
|
+
if (copycat !== undefined && copycat !== null && copycatRecord === undefined) {
|
|
476
|
+
warnings.push(`Manifest gate field "gate.copycat" must be a mapping; ignoring it.`);
|
|
477
|
+
}
|
|
478
|
+
const size = record.size;
|
|
479
|
+
const sizeRecord = size !== null && typeof size === "object" && !Array.isArray(size) ? size : undefined;
|
|
480
|
+
if (size !== undefined && size !== null && sizeRecord === undefined) {
|
|
481
|
+
warnings.push(`Manifest gate field "gate.size" must be a mapping; ignoring it.`);
|
|
482
|
+
}
|
|
483
|
+
const cla = record.cla;
|
|
484
|
+
const claRecord = cla !== null && typeof cla === "object" && !Array.isArray(cla) ? cla : undefined;
|
|
485
|
+
if (cla !== undefined && cla !== null && claRecord === undefined) {
|
|
486
|
+
warnings.push(`Manifest gate field "gate.cla" must be a mapping; ignoring it.`);
|
|
487
|
+
}
|
|
488
|
+
const gate = {
|
|
489
|
+
present: false,
|
|
490
|
+
enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings),
|
|
491
|
+
checkMode: normalizeOptionalEnum(record.checkMode, "gate.checkMode", ["required", "visible", "disabled"], warnings),
|
|
492
|
+
pack: normalizeOptionalEnum(record.pack, "gate.pack", ["gittensor", "oss-anti-slop"], warnings),
|
|
493
|
+
linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings),
|
|
494
|
+
duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings),
|
|
495
|
+
readinessMode: normalizeReadinessGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings),
|
|
496
|
+
readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings),
|
|
497
|
+
slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings),
|
|
498
|
+
slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings),
|
|
499
|
+
slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings),
|
|
500
|
+
sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings),
|
|
501
|
+
lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings),
|
|
502
|
+
aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
|
|
503
|
+
aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
|
|
504
|
+
aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"], warnings),
|
|
505
|
+
aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
|
|
506
|
+
aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
|
|
507
|
+
aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
|
|
508
|
+
aiReviewLowConfidenceDisposition: normalizeOptionalEnum(aiReviewRecord?.lowConfidenceDisposition, "gate.aiReview.lowConfidenceDisposition", ["one_shot", "hold_for_review", "advisory_only"], warnings),
|
|
509
|
+
aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"], warnings),
|
|
510
|
+
aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"], warnings),
|
|
511
|
+
aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings),
|
|
512
|
+
mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings),
|
|
513
|
+
manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings),
|
|
514
|
+
selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings),
|
|
515
|
+
linkedIssueSatisfaction: normalizeOptionalGateMode(record.linkedIssueSatisfaction, "gate.linkedIssueSatisfaction", warnings),
|
|
516
|
+
dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
|
|
517
|
+
firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
|
|
518
|
+
premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
|
|
519
|
+
requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings),
|
|
520
|
+
claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings),
|
|
521
|
+
claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings),
|
|
522
|
+
claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings),
|
|
523
|
+
claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings),
|
|
524
|
+
expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings),
|
|
525
|
+
aiJudgmentBlockersMode: normalizeOptionalEnum(record.aiJudgmentBlockers, "gate.aiJudgmentBlockers", ["gate", "advisory"], warnings),
|
|
526
|
+
copycatMode: normalizeOptionalEnum(copycatRecord?.mode, "gate.copycat.mode", ["off", "warn", "label", "block"], warnings),
|
|
527
|
+
copycatMinScore: normalizeOptionalScore(copycatRecord?.minScore, "gate.copycat.minScore", warnings),
|
|
528
|
+
};
|
|
529
|
+
// #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a
|
|
530
|
+
// maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface
|
|
531
|
+
// that inertness at parse time rather than leaving it silently no-op; `false`/unset matches the (also inert)
|
|
532
|
+
// default, so only an explicit `true` is worth flagging.
|
|
533
|
+
if (gate.firstTimeContributorGrace === true) {
|
|
534
|
+
warnings.push(`Manifest field "gate.firstTimeContributorGrace" is currently reserved/inert — it does not soften a blocker outcome for first-time contributors.`);
|
|
535
|
+
}
|
|
536
|
+
// gate.enabled only controls whether the "LoopOver Orb Review Agent" check-run publishes (the legacy
|
|
537
|
+
// enabled -> reviewCheckMode alias in applyGateConfigOverrides) -- it does NOT gate spend, merge, comment,
|
|
538
|
+
// label, or close behavior, and it cannot express checkMode's "visible" state. checkMode always wins when
|
|
539
|
+
// both are set, so once an operator sets checkMode, a lingering enabled becomes pure inert noise they have
|
|
540
|
+
// no reason to notice or remove -- only warn while checkMode is still absent (a paired enabled+checkMode
|
|
541
|
+
// config, the documented convention, is not worth flagging). Caused two real incidents under this exact
|
|
542
|
+
// ambiguity (an operator flipping enabled to restore unrelated spend/gate behavior got a surprise check-run
|
|
543
|
+
// side effect instead, twice).
|
|
544
|
+
if (gate.enabled !== null && gate.checkMode === null) {
|
|
545
|
+
warnings.push(`Manifest field "gate.enabled" only controls whether the LoopOver Orb Review Agent check-run publishes — it does not gate spend, merge, comment, label, or close behavior, and it cannot express reviewCheckMode's "visible" state. Set "gate.checkMode" (required/visible/disabled) explicitly instead of "gate.enabled".`);
|
|
546
|
+
}
|
|
547
|
+
gate.present =
|
|
548
|
+
gate.enabled !== null ||
|
|
549
|
+
gate.checkMode !== null ||
|
|
550
|
+
gate.pack !== null ||
|
|
551
|
+
gate.linkedIssue !== null ||
|
|
552
|
+
gate.duplicates !== null ||
|
|
553
|
+
gate.readinessMode !== null ||
|
|
554
|
+
gate.readinessMinScore !== null ||
|
|
555
|
+
gate.slopMode !== null ||
|
|
556
|
+
gate.slopMinScore !== null ||
|
|
557
|
+
gate.slopAiAdvisory !== null ||
|
|
558
|
+
gate.sizeMode !== null ||
|
|
559
|
+
gate.lockfileIntegrityMode !== null ||
|
|
560
|
+
gate.aiReviewMode !== null ||
|
|
561
|
+
gate.aiReviewByok !== null ||
|
|
562
|
+
gate.aiReviewProvider !== null ||
|
|
563
|
+
gate.aiReviewModel !== null ||
|
|
564
|
+
gate.aiReviewAllAuthors !== null ||
|
|
565
|
+
gate.aiReviewCloseConfidence !== null ||
|
|
566
|
+
gate.aiReviewLowConfidenceDisposition !== null ||
|
|
567
|
+
gate.aiReviewCombine !== null ||
|
|
568
|
+
gate.aiReviewOnMerge !== null ||
|
|
569
|
+
gate.aiReviewReviewers !== null ||
|
|
570
|
+
gate.mergeReadiness !== null ||
|
|
571
|
+
gate.manifestPolicy !== null ||
|
|
572
|
+
gate.selfAuthoredLinkedIssue !== null ||
|
|
573
|
+
gate.linkedIssueSatisfaction !== null ||
|
|
574
|
+
gate.dryRun !== null ||
|
|
575
|
+
gate.firstTimeContributorGrace !== null ||
|
|
576
|
+
gate.premergeContentRecheck !== null ||
|
|
577
|
+
gate.requireFreshRebaseWindowMinutes !== null ||
|
|
578
|
+
gate.claMode !== null ||
|
|
579
|
+
gate.claConsentPhrase !== null ||
|
|
580
|
+
gate.claCheckRunName !== null ||
|
|
581
|
+
gate.claCheckRunAppSlug !== null ||
|
|
582
|
+
gate.expectedCiContexts !== null ||
|
|
583
|
+
gate.aiJudgmentBlockersMode !== null ||
|
|
584
|
+
gate.copycatMode !== null ||
|
|
585
|
+
gate.copycatMinScore !== null;
|
|
586
|
+
return gate;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Serialize a gate config back into the parse-compatible `gate:` shape so a cached manifest snapshot
|
|
590
|
+
* round-trips through {@link parseGateConfig} unchanged. Returns null when nothing is configured.
|
|
591
|
+
*/
|
|
592
|
+
export function gateConfigToJson(gate) {
|
|
593
|
+
if (!gate.present)
|
|
594
|
+
return null;
|
|
595
|
+
const out = {};
|
|
596
|
+
if (gate.enabled !== null)
|
|
597
|
+
out.enabled = gate.enabled;
|
|
598
|
+
if (gate.checkMode !== null)
|
|
599
|
+
out.checkMode = gate.checkMode;
|
|
600
|
+
if (gate.pack !== null)
|
|
601
|
+
out.pack = gate.pack;
|
|
602
|
+
if (gate.linkedIssue !== null)
|
|
603
|
+
out.linkedIssue = gate.linkedIssue;
|
|
604
|
+
if (gate.duplicates !== null)
|
|
605
|
+
out.duplicates = gate.duplicates;
|
|
606
|
+
if (gate.readinessMode !== null || gate.readinessMinScore !== null) {
|
|
607
|
+
const readiness = {};
|
|
608
|
+
if (gate.readinessMode !== null)
|
|
609
|
+
readiness.mode = gate.readinessMode;
|
|
610
|
+
if (gate.readinessMinScore !== null)
|
|
611
|
+
readiness.minScore = gate.readinessMinScore;
|
|
612
|
+
out.readiness = readiness;
|
|
613
|
+
}
|
|
614
|
+
if (gate.sizeMode !== null)
|
|
615
|
+
out.size = { mode: gate.sizeMode };
|
|
616
|
+
if (gate.lockfileIntegrityMode !== null)
|
|
617
|
+
out.lockfileIntegrity = gate.lockfileIntegrityMode;
|
|
618
|
+
if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) {
|
|
619
|
+
const slop = {};
|
|
620
|
+
if (gate.slopMode !== null)
|
|
621
|
+
slop.mode = gate.slopMode;
|
|
622
|
+
if (gate.slopMinScore !== null)
|
|
623
|
+
slop.minScore = gate.slopMinScore;
|
|
624
|
+
if (gate.slopAiAdvisory !== null)
|
|
625
|
+
slop.aiAdvisory = gate.slopAiAdvisory;
|
|
626
|
+
out.slop = slop;
|
|
627
|
+
}
|
|
628
|
+
if (gate.aiReviewMode !== null ||
|
|
629
|
+
gate.aiReviewByok !== null ||
|
|
630
|
+
gate.aiReviewProvider !== null ||
|
|
631
|
+
gate.aiReviewModel !== null ||
|
|
632
|
+
gate.aiReviewAllAuthors !== null ||
|
|
633
|
+
gate.aiReviewCloseConfidence !== null ||
|
|
634
|
+
gate.aiReviewLowConfidenceDisposition !== null ||
|
|
635
|
+
gate.aiReviewCombine !== null ||
|
|
636
|
+
gate.aiReviewOnMerge !== null ||
|
|
637
|
+
gate.aiReviewReviewers !== null) {
|
|
638
|
+
const aiReview = {};
|
|
639
|
+
if (gate.aiReviewMode !== null)
|
|
640
|
+
aiReview.mode = gate.aiReviewMode;
|
|
641
|
+
if (gate.aiReviewByok !== null)
|
|
642
|
+
aiReview.byok = gate.aiReviewByok;
|
|
643
|
+
if (gate.aiReviewProvider !== null)
|
|
644
|
+
aiReview.provider = gate.aiReviewProvider;
|
|
645
|
+
if (gate.aiReviewModel !== null)
|
|
646
|
+
aiReview.model = gate.aiReviewModel;
|
|
647
|
+
if (gate.aiReviewAllAuthors !== null)
|
|
648
|
+
aiReview.allAuthors = gate.aiReviewAllAuthors;
|
|
649
|
+
if (gate.aiReviewCloseConfidence !== null)
|
|
650
|
+
aiReview.closeConfidence = gate.aiReviewCloseConfidence;
|
|
651
|
+
if (gate.aiReviewLowConfidenceDisposition !== null)
|
|
652
|
+
aiReview.lowConfidenceDisposition = gate.aiReviewLowConfidenceDisposition;
|
|
653
|
+
if (gate.aiReviewCombine !== null)
|
|
654
|
+
aiReview.combine = gate.aiReviewCombine;
|
|
655
|
+
if (gate.aiReviewOnMerge !== null)
|
|
656
|
+
aiReview.onMerge = gate.aiReviewOnMerge;
|
|
657
|
+
if (gate.aiReviewReviewers !== null) {
|
|
658
|
+
aiReview.reviewers = gate.aiReviewReviewers.map((r) => r.fallback ? { model: r.model, fallback: r.fallback } : { model: r.model });
|
|
659
|
+
}
|
|
660
|
+
out.aiReview = aiReview;
|
|
661
|
+
}
|
|
662
|
+
if (gate.mergeReadiness !== null)
|
|
663
|
+
out.mergeReadiness = gate.mergeReadiness;
|
|
664
|
+
if (gate.manifestPolicy !== null)
|
|
665
|
+
out.manifestPolicy = gate.manifestPolicy;
|
|
666
|
+
if (gate.selfAuthoredLinkedIssue !== null)
|
|
667
|
+
out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue;
|
|
668
|
+
if (gate.linkedIssueSatisfaction !== null)
|
|
669
|
+
out.linkedIssueSatisfaction = gate.linkedIssueSatisfaction;
|
|
670
|
+
if (gate.dryRun !== null)
|
|
671
|
+
out.dryRun = gate.dryRun;
|
|
672
|
+
if (gate.firstTimeContributorGrace !== null)
|
|
673
|
+
out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
|
|
674
|
+
if (gate.premergeContentRecheck !== null)
|
|
675
|
+
out.premergeContentRecheck = gate.premergeContentRecheck;
|
|
676
|
+
if (gate.requireFreshRebaseWindowMinutes !== null)
|
|
677
|
+
out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes;
|
|
678
|
+
if (gate.claMode !== null)
|
|
679
|
+
out.claMode = gate.claMode;
|
|
680
|
+
if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) {
|
|
681
|
+
const cla = {};
|
|
682
|
+
if (gate.claConsentPhrase !== null)
|
|
683
|
+
cla.consentPhrase = gate.claConsentPhrase;
|
|
684
|
+
if (gate.claCheckRunName !== null)
|
|
685
|
+
cla.checkRunName = gate.claCheckRunName;
|
|
686
|
+
if (gate.claCheckRunAppSlug !== null)
|
|
687
|
+
cla.checkRunAppSlug = gate.claCheckRunAppSlug;
|
|
688
|
+
out.cla = cla;
|
|
689
|
+
}
|
|
690
|
+
if (gate.expectedCiContexts !== null)
|
|
691
|
+
out.expectedCiContexts = gate.expectedCiContexts;
|
|
692
|
+
if (gate.aiJudgmentBlockersMode !== null)
|
|
693
|
+
out.aiJudgmentBlockers = gate.aiJudgmentBlockersMode;
|
|
694
|
+
if (gate.copycatMode !== null || gate.copycatMinScore !== null) {
|
|
695
|
+
const copycat = {};
|
|
696
|
+
if (gate.copycatMode !== null)
|
|
697
|
+
copycat.mode = gate.copycatMode;
|
|
698
|
+
if (gate.copycatMinScore !== null)
|
|
699
|
+
copycat.minScore = gate.copycatMinScore;
|
|
700
|
+
out.copycat = copycat;
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Parse the optional `features:` mapping — per-repo activation overrides for the converged review features.
|
|
706
|
+
* Each recognized key becomes a tri-state (`true`/`false`/`null`); unknown keys and non-boolean values are
|
|
707
|
+
* dropped with a warning. `present` is true when at least one key was explicitly set, so an operator can make
|
|
708
|
+
* the manifest "present" with only a `features:` block.
|
|
709
|
+
*/
|
|
710
|
+
function parseFeaturesConfig(value, warnings) {
|
|
711
|
+
const features = { ...EMPTY_FEATURES_CONFIG };
|
|
712
|
+
if (value === undefined || value === null)
|
|
713
|
+
return features;
|
|
714
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
715
|
+
warnings.push('Manifest "features" must be a mapping; ignoring it.');
|
|
716
|
+
return features;
|
|
717
|
+
}
|
|
718
|
+
const record = value;
|
|
719
|
+
for (const key of CONVERGED_FEATURE_KEYS) {
|
|
720
|
+
features[key] = normalizeOptionalBoolean(record[key], `features.${key}`, warnings);
|
|
721
|
+
}
|
|
722
|
+
features.present = CONVERGED_FEATURE_KEYS.some((key) => features[key] !== null);
|
|
723
|
+
return features;
|
|
724
|
+
}
|
|
725
|
+
/** Serialize a features config back into the parse-compatible `features:` shape so a cached snapshot round-trips
|
|
726
|
+
* through {@link parseFeaturesConfig} unchanged. Returns null when nothing is configured. */
|
|
727
|
+
export function featuresConfigToJson(features) {
|
|
728
|
+
if (!features.present)
|
|
729
|
+
return null;
|
|
730
|
+
const out = {};
|
|
731
|
+
for (const key of CONVERGED_FEATURE_KEYS) {
|
|
732
|
+
if (features[key] !== null)
|
|
733
|
+
out[key] = features[key];
|
|
734
|
+
}
|
|
735
|
+
return out;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Parse the optional `experimental:` mapping — per-repo activation for optional ecosystem/network plugins
|
|
739
|
+
* (starting with `gittensor`, the subnet mining/scoring integration). Mirrors parseFeaturesConfig's shape and
|
|
740
|
+
* validation; kept as a SEPARATE top-level block from `features:` so plugin integrations that couple gittensory
|
|
741
|
+
* to an external network stay visibly distinct from the converged REVIEW capabilities `features:` toggles, and
|
|
742
|
+
* so future plugins land in the same place without touching `features:`'s semantics.
|
|
743
|
+
*/
|
|
744
|
+
function parseExperimentalConfig(value, warnings) {
|
|
745
|
+
const experimental = { ...EMPTY_EXPERIMENTAL_CONFIG };
|
|
746
|
+
if (value === undefined || value === null)
|
|
747
|
+
return experimental;
|
|
748
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
749
|
+
warnings.push('Manifest "experimental" must be a mapping; ignoring it.');
|
|
750
|
+
return experimental;
|
|
751
|
+
}
|
|
752
|
+
const record = value;
|
|
753
|
+
for (const key of EXPERIMENTAL_PLUGIN_KEYS) {
|
|
754
|
+
experimental[key] = normalizeOptionalBoolean(record[key], `experimental.${key}`, warnings);
|
|
755
|
+
}
|
|
756
|
+
experimental.present = EXPERIMENTAL_PLUGIN_KEYS.some((key) => experimental[key] !== null);
|
|
757
|
+
return experimental;
|
|
758
|
+
}
|
|
759
|
+
/** Serialize an experimental config back into the parse-compatible `experimental:` shape so a cached snapshot
|
|
760
|
+
* round-trips through {@link parseExperimentalConfig} unchanged. Returns null when nothing is configured. */
|
|
761
|
+
export function experimentalConfigToJson(experimental) {
|
|
762
|
+
if (!experimental.present)
|
|
763
|
+
return null;
|
|
764
|
+
const out = {};
|
|
765
|
+
for (const key of EXPERIMENTAL_PLUGIN_KEYS) {
|
|
766
|
+
if (experimental[key] !== null)
|
|
767
|
+
out[key] = experimental[key];
|
|
768
|
+
}
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
/** A positive INTEGER count (not a score/confidence) — e.g. `contentLane.maxAppendedEntries` counts discrete
|
|
772
|
+
* surfaces[] entries, so a fractional value (a likely typo) would render a nonsensical contributor-facing close
|
|
773
|
+
* message ("append between 1 and 2.5 entries"). Rejects fractional and non-positive values alike. */
|
|
774
|
+
function normalizeOptionalPositiveInteger(value, field, warnings) {
|
|
775
|
+
if (value === undefined || value === null)
|
|
776
|
+
return null;
|
|
777
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
778
|
+
return value;
|
|
779
|
+
warnings.push(`Manifest field "${field}" must be a positive whole number; ignoring it.`);
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100;
|
|
783
|
+
function normalizeOptionalContributorOpenItemCap(value, field, warnings) {
|
|
784
|
+
const parsed = normalizeOptionalPositiveInteger(value, field, warnings);
|
|
785
|
+
if (parsed === null)
|
|
786
|
+
return null;
|
|
787
|
+
return Math.min(parsed, MAX_CONTRIBUTOR_OPEN_ITEM_CAP);
|
|
788
|
+
}
|
|
789
|
+
const REVIEW_VISUAL_MAX_ROUTES_LIMIT = 5;
|
|
790
|
+
function normalizeOptionalVisualMaxRoutes(value, warnings) {
|
|
791
|
+
const maxRoutes = normalizeOptionalPositiveInteger(value, "review.visual.routes.max_routes", warnings);
|
|
792
|
+
if (maxRoutes === null)
|
|
793
|
+
return null;
|
|
794
|
+
if (maxRoutes <= REVIEW_VISUAL_MAX_ROUTES_LIMIT)
|
|
795
|
+
return maxRoutes;
|
|
796
|
+
warnings.push(`Manifest field "review.visual.routes.max_routes" must be at most ${REVIEW_VISUAL_MAX_ROUTES_LIMIT}; clamping it.`);
|
|
797
|
+
return REVIEW_VISUAL_MAX_ROUTES_LIMIT;
|
|
798
|
+
}
|
|
799
|
+
/** Normalize + bound a maintainer-supplied glob string: trims/length-caps like any other string field, AND
|
|
800
|
+
* rejects one globToRegExp (review/content-lane/spec-resolver.ts's reuse of the guardrail-path compiler) would
|
|
801
|
+
* itself refuse to compile safely. Reuses `hasUnsafeWildcardCount` — globToRegExp's OWN safety predicate —
|
|
802
|
+
* rather than a locally-counted threshold: a caller that counts wildcards differently (e.g. raw `*` characters,
|
|
803
|
+
* which double-counts a `**` pair as 2 groups instead of 1) can accept a glob globToRegExp then silently
|
|
804
|
+
* compiles to NEVER_MATCHES, configuring a lane that is "present" but can never activate on any changed file
|
|
805
|
+
* (#confirmed-bug). A glob over the cap is REJECTED (warns, returns null) rather than truncated — silently
|
|
806
|
+
* cutting wildcards out of a maintainer's pattern would silently change its meaning, which is worse than making
|
|
807
|
+
* them fix an over-complex glob. */
|
|
808
|
+
function normalizeOptionalGlob(value, field, warnings) {
|
|
809
|
+
const normalized = normalizeOptionalString(value, field, warnings);
|
|
810
|
+
if (normalized === null)
|
|
811
|
+
return null;
|
|
812
|
+
if (normalized.length > MAX_ITEM_LENGTH) {
|
|
813
|
+
// REJECT, not truncate: cutting characters out of a glob changes which files it matches (e.g. a
|
|
814
|
+
// mid-directory-name cut can turn a narrow, intended pattern into one that matches an unrelated path
|
|
815
|
+
// prefix, or one that never matches anything) — silently compiling a DIFFERENT pattern than the
|
|
816
|
+
// maintainer configured is worse than making them shorten an over-complex glob.
|
|
817
|
+
warnings.push(`Manifest field "${field}" is an over-long glob (${normalized.length} > ${MAX_ITEM_LENGTH} chars); ignoring it.`);
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
if (hasUnsafeWildcardCount(normalized)) {
|
|
821
|
+
warnings.push(`Manifest field "${field}" has too many wildcards to compile safely; ignoring it.`);
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
return normalized;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Parse the optional `contentLane:` mapping — per-repo registry-review lane configuration (#2435). `entryFileGlob`
|
|
828
|
+
* and `collectionField` are REQUIRED to build a usable spec; a config missing either — including a glob rejected
|
|
829
|
+
* by `normalizeOptionalGlob`'s wildcard cap — degrades to "not configured" (a warning, falling through to the
|
|
830
|
+
* allowlist default) rather than a broken half-spec. Glob fields stay plain strings here — compiling them to
|
|
831
|
+
* RegExp is the resolver's job (`review/content-lane/spec-resolver.ts`), not the parser's, so this file stays
|
|
832
|
+
* free of a RegExp-from-config compile step; it's still this file's job to keep an over-complex glob from ever
|
|
833
|
+
* reaching that compile step at all.
|
|
834
|
+
*/
|
|
835
|
+
function parseContentLaneConfig(value, warnings) {
|
|
836
|
+
if (value === undefined || value === null)
|
|
837
|
+
return { ...EMPTY_CONTENT_LANE_CONFIG };
|
|
838
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
839
|
+
warnings.push('Manifest field "contentLane" must be a mapping; ignoring it.');
|
|
840
|
+
return { ...EMPTY_CONTENT_LANE_CONFIG };
|
|
841
|
+
}
|
|
842
|
+
const record = value;
|
|
843
|
+
const entryFileGlob = normalizeOptionalGlob(record.entryFileGlob, "contentLane.entryFileGlob", warnings);
|
|
844
|
+
const providerFileGlob = normalizeOptionalGlob(record.providerFileGlob, "contentLane.providerFileGlob", warnings);
|
|
845
|
+
const artifactGlob = normalizeOptionalGlob(record.artifactGlob, "contentLane.artifactGlob", warnings);
|
|
846
|
+
const collectionField = normalizeOptionalString(record.collectionField, "contentLane.collectionField", warnings);
|
|
847
|
+
const maxAppendedEntries = normalizeOptionalPositiveInteger(record.maxAppendedEntries, "contentLane.maxAppendedEntries", warnings);
|
|
848
|
+
const duplicateKeyFields = normalizeStringList(record.duplicateKeyFields, "contentLane.duplicateKeyFields", warnings);
|
|
849
|
+
const validatorId = normalizeOptionalString(record.validatorId, "contentLane.validatorId", warnings);
|
|
850
|
+
if (!entryFileGlob || !collectionField) {
|
|
851
|
+
warnings.push('Manifest field "contentLane" requires both entryFileGlob and collectionField; ignoring it.');
|
|
852
|
+
return { ...EMPTY_CONTENT_LANE_CONFIG };
|
|
853
|
+
}
|
|
854
|
+
return { present: true, entryFileGlob, providerFileGlob, artifactGlob, collectionField, maxAppendedEntries, duplicateKeyFields, validatorId };
|
|
855
|
+
}
|
|
856
|
+
/** Serialize a contentLane config back into the parse-compatible `contentLane:` shape so a cached snapshot
|
|
857
|
+
* round-trips through {@link parseContentLaneConfig} unchanged. Returns null when nothing is configured. */
|
|
858
|
+
export function contentLaneConfigToJson(contentLane) {
|
|
859
|
+
if (!contentLane.present || !contentLane.entryFileGlob || !contentLane.collectionField)
|
|
860
|
+
return null;
|
|
861
|
+
const out = { entryFileGlob: contentLane.entryFileGlob, collectionField: contentLane.collectionField };
|
|
862
|
+
if (contentLane.providerFileGlob !== null)
|
|
863
|
+
out.providerFileGlob = contentLane.providerFileGlob;
|
|
864
|
+
if (contentLane.artifactGlob !== null)
|
|
865
|
+
out.artifactGlob = contentLane.artifactGlob;
|
|
866
|
+
if (contentLane.maxAppendedEntries !== null)
|
|
867
|
+
out.maxAppendedEntries = contentLane.maxAppendedEntries;
|
|
868
|
+
if (contentLane.duplicateKeyFields.length > 0)
|
|
869
|
+
out.duplicateKeyFields = contentLane.duplicateKeyFields;
|
|
870
|
+
if (contentLane.validatorId !== null)
|
|
871
|
+
out.validatorId = contentLane.validatorId;
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
const REPO_DOC_GENERATION_SCOPES = ["agents", "skills"];
|
|
875
|
+
/** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error
|
|
876
|
+
* and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise
|
|
877
|
+
* `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is
|
|
878
|
+
* invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */
|
|
879
|
+
function parseRepoDocGenerationScope(value, warnings) {
|
|
880
|
+
if (value === undefined || value === null)
|
|
881
|
+
return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
|
|
882
|
+
if (!Array.isArray(value)) {
|
|
883
|
+
warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.');
|
|
884
|
+
return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
|
|
885
|
+
}
|
|
886
|
+
const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings);
|
|
887
|
+
return raw.filter((entry) => {
|
|
888
|
+
if (REPO_DOC_GENERATION_SCOPES.includes(entry))
|
|
889
|
+
return true;
|
|
890
|
+
warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`);
|
|
891
|
+
return false;
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a
|
|
896
|
+
* concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the
|
|
897
|
+
* parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []`
|
|
898
|
+
* is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to
|
|
899
|
+
* `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file.
|
|
900
|
+
*/
|
|
901
|
+
function parseRepoDocGenerationConfig(value, warnings) {
|
|
902
|
+
if (value === undefined || value === null)
|
|
903
|
+
return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
|
|
904
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
905
|
+
warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.');
|
|
906
|
+
return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
|
|
907
|
+
}
|
|
908
|
+
const record = value;
|
|
909
|
+
const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false;
|
|
910
|
+
const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false;
|
|
911
|
+
const scope = parseRepoDocGenerationScope(record.scope, warnings);
|
|
912
|
+
const refreshIntervalDays = normalizeOptionalPositiveInteger(record.refreshIntervalDays, "repoDocGeneration.refreshIntervalDays", warnings) ?? DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS;
|
|
913
|
+
return { present: true, enabled, scope, allowOverwriteExisting, refreshIntervalDays };
|
|
914
|
+
}
|
|
915
|
+
/** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips
|
|
916
|
+
* through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */
|
|
917
|
+
export function repoDocGenerationConfigToJson(config) {
|
|
918
|
+
if (!config.present)
|
|
919
|
+
return null;
|
|
920
|
+
return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting, refreshIntervalDays: config.refreshIntervalDays };
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Parse the optional `reviewRecap:` mapping (#1963). Mirrors {@link parseRepoDocGenerationConfig}: every
|
|
924
|
+
* field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value.
|
|
925
|
+
*/
|
|
926
|
+
function parseReviewRecapConfig(value, warnings) {
|
|
927
|
+
if (value === undefined || value === null)
|
|
928
|
+
return { ...EMPTY_REVIEW_RECAP_CONFIG };
|
|
929
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
930
|
+
warnings.push('Manifest field "reviewRecap" must be a mapping; ignoring it.');
|
|
931
|
+
return { ...EMPTY_REVIEW_RECAP_CONFIG };
|
|
932
|
+
}
|
|
933
|
+
const record = value;
|
|
934
|
+
const enabled = normalizeOptionalBoolean(record.enabled, "reviewRecap.enabled", warnings) ?? false;
|
|
935
|
+
const cadenceDays = normalizeOptionalPositiveInteger(record.cadenceDays, "reviewRecap.cadenceDays", warnings) ?? DEFAULT_REVIEW_RECAP_CADENCE_DAYS;
|
|
936
|
+
return { present: true, enabled, cadenceDays };
|
|
937
|
+
}
|
|
938
|
+
/** Serialize a reviewRecap config back into the parse-compatible shape so a cached snapshot round-trips
|
|
939
|
+
* through {@link parseReviewRecapConfig} unchanged. Returns null when nothing is configured. */
|
|
940
|
+
export function reviewRecapConfigToJson(config) {
|
|
941
|
+
if (!config.present)
|
|
942
|
+
return null;
|
|
943
|
+
return { enabled: config.enabled, cadenceDays: config.cadenceDays };
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Parse the optional `maintainerRecap:` mapping (#1963, #2250). Mirrors {@link parseReviewRecapConfig}: every
|
|
947
|
+
* field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value. An
|
|
948
|
+
* invalid `cadence`/`channel` falls back to its default via {@link normalizeEnum} (with a warning) rather than
|
|
949
|
+
* silently firing more often or targeting an unsupported channel.
|
|
950
|
+
*/
|
|
951
|
+
function parseMaintainerRecapConfig(value, warnings) {
|
|
952
|
+
if (value === undefined || value === null)
|
|
953
|
+
return { ...EMPTY_MAINTAINER_RECAP_CONFIG };
|
|
954
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
955
|
+
warnings.push('Manifest field "maintainerRecap" must be a mapping; ignoring it.');
|
|
956
|
+
return { ...EMPTY_MAINTAINER_RECAP_CONFIG };
|
|
957
|
+
}
|
|
958
|
+
const record = value;
|
|
959
|
+
const enabled = normalizeOptionalBoolean(record.enabled, "maintainerRecap.enabled", warnings) ?? false;
|
|
960
|
+
const cadence = normalizeEnum(record.cadence, "maintainerRecap.cadence", ["daily", "weekly"], DEFAULT_MAINTAINER_RECAP_CADENCE, warnings);
|
|
961
|
+
const channel = normalizeEnum(record.channel, "maintainerRecap.channel", ["discord"], DEFAULT_MAINTAINER_RECAP_CHANNEL, warnings);
|
|
962
|
+
return { present: true, enabled, cadence, channel };
|
|
963
|
+
}
|
|
964
|
+
/** Serialize a maintainerRecap config back into the parse-compatible shape so a cached snapshot round-trips
|
|
965
|
+
* through {@link parseMaintainerRecapConfig} unchanged. Returns null when nothing is configured. */
|
|
966
|
+
export function maintainerRecapConfigToJson(config) {
|
|
967
|
+
if (!config.present)
|
|
968
|
+
return null;
|
|
969
|
+
return { enabled: config.enabled, cadence: config.cadence, channel: config.channel };
|
|
970
|
+
}
|
|
971
|
+
function normalizeOptionalEnum(value, field, allowed, warnings) {
|
|
972
|
+
if (value === undefined || value === null)
|
|
973
|
+
return null;
|
|
974
|
+
if (typeof value === "string" && allowed.includes(value))
|
|
975
|
+
return value;
|
|
976
|
+
warnings.push(`Manifest settings field "${field}" must be one of ${allowed.join(", ")}; ignoring "${String(value)}".`);
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
function normalizeOptionalString(value, field, warnings) {
|
|
980
|
+
if (value === undefined || value === null)
|
|
981
|
+
return null;
|
|
982
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
983
|
+
return value.trim();
|
|
984
|
+
warnings.push(`Manifest settings field "${field}" must be a non-empty string; ignoring it.`);
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
// Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date
|
|
988
|
+
// arithmetic. Duplicated from settings/agent-actions.ts's own MAX_REVIEW_NAG_COOLDOWN_DAYS (same value,
|
|
989
|
+
// same rationale) rather than imported: this module is part of the UI package's typechecked closure, and
|
|
990
|
+
// agent-actions.ts transitively imports github/commands.ts -> utils/crypto.ts, pulling a heavier
|
|
991
|
+
// GitHub-App-specific dependency chain into the UI build for one small constant.
|
|
992
|
+
const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365;
|
|
993
|
+
/**
|
|
994
|
+
* Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized
|
|
995
|
+
* fields are kept; unknown/invalid values are dropped with a warning and never throw.
|
|
996
|
+
*/
|
|
997
|
+
function parseSettingsOverride(value, warnings, source) {
|
|
998
|
+
if (value === undefined || value === null)
|
|
999
|
+
return {};
|
|
1000
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1001
|
+
warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`);
|
|
1002
|
+
return {};
|
|
1003
|
+
}
|
|
1004
|
+
const r = value;
|
|
1005
|
+
const out = {};
|
|
1006
|
+
const commentMode = normalizeOptionalEnum(r.commentMode, "settings.commentMode", ["off", "detected_contributors_only", "all_prs"], warnings);
|
|
1007
|
+
if (commentMode !== null)
|
|
1008
|
+
out.commentMode = commentMode;
|
|
1009
|
+
const publicAudienceMode = normalizeOptionalEnum(r.publicAudienceMode, "settings.publicAudienceMode", ["oss_maintainer", "gittensor_only"], warnings);
|
|
1010
|
+
if (publicAudienceMode !== null)
|
|
1011
|
+
out.publicAudienceMode = publicAudienceMode;
|
|
1012
|
+
const publicSignalLevel = normalizeOptionalEnum(r.publicSignalLevel, "settings.publicSignalLevel", ["minimal", "standard"], warnings);
|
|
1013
|
+
if (publicSignalLevel !== null)
|
|
1014
|
+
out.publicSignalLevel = publicSignalLevel;
|
|
1015
|
+
const checkRunMode = normalizeOptionalEnum(r.checkRunMode, "settings.checkRunMode", ["off", "enabled"], warnings);
|
|
1016
|
+
if (checkRunMode !== null)
|
|
1017
|
+
out.checkRunMode = checkRunMode;
|
|
1018
|
+
const checkRunDetailLevel = normalizeOptionalEnum(r.checkRunDetailLevel, "settings.checkRunDetailLevel", ["minimal", "standard"], warnings);
|
|
1019
|
+
if (checkRunDetailLevel !== null)
|
|
1020
|
+
out.checkRunDetailLevel = checkRunDetailLevel;
|
|
1021
|
+
const regateSweepOrderMode = normalizeOptionalEnum(r.regateSweepOrderMode, "settings.regateSweepOrderMode", ["staleness", "oldest-first"], warnings);
|
|
1022
|
+
if (regateSweepOrderMode !== null)
|
|
1023
|
+
out.regateSweepOrderMode = regateSweepOrderMode;
|
|
1024
|
+
// Same tri-state field as gate.checkMode above (the friendly gate alias overlays onto it in
|
|
1025
|
+
// resolveEffectiveSettings, and wins when both are set).
|
|
1026
|
+
const reviewCheckMode = normalizeOptionalEnum(r.reviewCheckMode, "settings.reviewCheckMode", ["required", "visible", "disabled"], warnings);
|
|
1027
|
+
if (reviewCheckMode !== null)
|
|
1028
|
+
out.reviewCheckMode = reviewCheckMode;
|
|
1029
|
+
const autoProjectMilestoneMatch = normalizeOptionalEnum(r.autoProjectMilestoneMatch, "settings.autoProjectMilestoneMatch", ["off", "suggest", "auto"], warnings);
|
|
1030
|
+
if (autoProjectMilestoneMatch !== null)
|
|
1031
|
+
out.autoProjectMilestoneMatch = autoProjectMilestoneMatch;
|
|
1032
|
+
const autoProjectMilestoneMatchBackend = normalizeOptionalEnum(r.autoProjectMilestoneMatchBackend, "settings.autoProjectMilestoneMatchBackend", ["github", "linear"], warnings);
|
|
1033
|
+
if (autoProjectMilestoneMatchBackend !== null)
|
|
1034
|
+
out.autoProjectMilestoneMatchBackend = autoProjectMilestoneMatchBackend;
|
|
1035
|
+
const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings);
|
|
1036
|
+
if (linkedIssueGateMode !== null)
|
|
1037
|
+
out.linkedIssueGateMode = linkedIssueGateMode;
|
|
1038
|
+
const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings);
|
|
1039
|
+
if (duplicatePrGateMode !== null)
|
|
1040
|
+
out.duplicatePrGateMode = duplicatePrGateMode;
|
|
1041
|
+
const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings);
|
|
1042
|
+
if (selfAuthoredLinkedIssueGateMode !== null)
|
|
1043
|
+
out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode;
|
|
1044
|
+
// Same tri-state field as gate.readiness.mode above (the friendly gate alias overlays onto it in
|
|
1045
|
+
// resolveEffectiveSettings) — apply the identical "block" → "advisory" downgrade here too, so a maintainer
|
|
1046
|
+
// setting `settings.qualityGateMode: block` directly hits the same deprecation warning (#2267).
|
|
1047
|
+
const qualityGateMode = normalizeReadinessGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings);
|
|
1048
|
+
if (qualityGateMode !== null)
|
|
1049
|
+
out.qualityGateMode = qualityGateMode;
|
|
1050
|
+
const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings);
|
|
1051
|
+
if (qualityGateMinScore !== null)
|
|
1052
|
+
out.qualityGateMinScore = qualityGateMinScore;
|
|
1053
|
+
const aiReviewMode = normalizeOptionalGateMode(r.aiReviewMode, "settings.aiReviewMode", warnings);
|
|
1054
|
+
if (aiReviewMode !== null)
|
|
1055
|
+
out.aiReviewMode = aiReviewMode;
|
|
1056
|
+
const aiReviewProvider = normalizeOptionalEnum(r.aiReviewProvider, "settings.aiReviewProvider", ["anthropic", "openai"], warnings);
|
|
1057
|
+
if (aiReviewProvider !== null)
|
|
1058
|
+
out.aiReviewProvider = aiReviewProvider;
|
|
1059
|
+
const aiReviewModel = normalizeOptionalString(r.aiReviewModel, "settings.aiReviewModel", warnings);
|
|
1060
|
+
if (aiReviewModel !== null)
|
|
1061
|
+
out.aiReviewModel = aiReviewModel;
|
|
1062
|
+
const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings);
|
|
1063
|
+
if (gittensorLabel !== null)
|
|
1064
|
+
out.gittensorLabel = gittensorLabel;
|
|
1065
|
+
// #label-scoping: an explicit yml `null` is load-bearing (closes WITHOUT any label), matching
|
|
1066
|
+
// contributorOpenPrCap's own null-vs-omitted distinction — must be checked BEFORE normalizeOptionalString,
|
|
1067
|
+
// which otherwise collapses null and undefined to the same "unset" result.
|
|
1068
|
+
if (r.blacklistLabel === null) {
|
|
1069
|
+
out.blacklistLabel = null;
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const blacklistLabel = normalizeOptionalString(r.blacklistLabel, "settings.blacklistLabel", warnings);
|
|
1073
|
+
if (blacklistLabel !== null)
|
|
1074
|
+
out.blacklistLabel = blacklistLabel;
|
|
1075
|
+
}
|
|
1076
|
+
const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"], warnings);
|
|
1077
|
+
if (publicSurface !== null)
|
|
1078
|
+
out.publicSurface = publicSurface;
|
|
1079
|
+
for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun"]) {
|
|
1080
|
+
const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
|
|
1081
|
+
if (flag !== null)
|
|
1082
|
+
out[key] = flag;
|
|
1083
|
+
}
|
|
1084
|
+
// agentGlobalFreezeOverride is deliberately NOT in the generic boolean loop above (#4372/#4391/operator-only-
|
|
1085
|
+
// freeze-fix): it is an OPERATOR-ONLY emergency lever ("re-activate this one repo while the fleet-wide kill-
|
|
1086
|
+
// switch stays on elsewhere"), and every OTHER settings field in that loop is readable from BOTH the public,
|
|
1087
|
+
// maintainer-owned `.gittensory.yml` committed in the repo's own git history (source: "repo_file") AND the
|
|
1088
|
+
// operator's private, container-local self-host config (source: "api_record") -- see loadRepoFocusManifestWithCachePolicy
|
|
1089
|
+
// in focus-manifest-loader.ts for how each source is produced. A repo MAINTAINER must never be able to grant
|
|
1090
|
+
// their own repo an exemption from the operator's fleet-wide freeze via their own committed yml (that is
|
|
1091
|
+
// exactly the "scope leak" #4391 closed by stripping this field from the shared loop entirely). But the
|
|
1092
|
+
// OPERATOR's own private config source is a fundamentally different trust boundary -- it is edited only by
|
|
1093
|
+
// whoever has filesystem access to the container's private config directory, not by any repo's maintainers --
|
|
1094
|
+
// and #4391 over-corrected by also removing the operator's own legitimate, config-as-code path for this lever,
|
|
1095
|
+
// forcing raw undocumented DB writes as the only remaining mechanism (violating this project's config-as-code
|
|
1096
|
+
// convention: every operator-facing control belongs in the global-default + per-repo-override config files,
|
|
1097
|
+
// env vars are for bootstrap only). Restore it, gated STRICTLY to the private source.
|
|
1098
|
+
if (source === "api_record") {
|
|
1099
|
+
const agentGlobalFreezeOverride = normalizeOptionalBoolean(r.agentGlobalFreezeOverride, "settings.agentGlobalFreezeOverride", warnings);
|
|
1100
|
+
if (agentGlobalFreezeOverride !== null)
|
|
1101
|
+
out.agentGlobalFreezeOverride = agentGlobalFreezeOverride;
|
|
1102
|
+
}
|
|
1103
|
+
else if (r.agentGlobalFreezeOverride !== undefined) {
|
|
1104
|
+
// A public/maintainer-owned manifest attempting to set this is silently dropped, not surfaced as a normal
|
|
1105
|
+
// "invalid value" warning -- warnings are public-safe text that can reach a contributor-facing preview, and
|
|
1106
|
+
// this should not teach a non-operator that the field exists or that they almost bypassed the fleet freeze.
|
|
1107
|
+
warnings.push("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest.");
|
|
1108
|
+
}
|
|
1109
|
+
// Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it
|
|
1110
|
+
// when at least one valid class→level pair survives normalization, so a malformed block never blanks the
|
|
1111
|
+
// DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay.
|
|
1112
|
+
if (r.autonomy !== undefined) {
|
|
1113
|
+
const autonomy = normalizeAutonomyPolicy(r.autonomy);
|
|
1114
|
+
if (Object.keys(autonomy).length > 0)
|
|
1115
|
+
out.autonomy = autonomy;
|
|
1116
|
+
}
|
|
1117
|
+
// Auto-maintain policy (#774): `settings.autoMaintain` declares the full policy (defaults fill any unset
|
|
1118
|
+
// field) and overlays the DB value via the resolver. Only a mapping is honoured; anything else is ignored.
|
|
1119
|
+
if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) {
|
|
1120
|
+
out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain);
|
|
1121
|
+
}
|
|
1122
|
+
// Command authorization policy (#2268 config-as-code parity): `settings.commandAuthorization` declares the
|
|
1123
|
+
// full role policy the same way `autoMaintain` does — the normalizer fills any unset/invalid FIELD from
|
|
1124
|
+
// DEFAULT_COMMAND_AUTHORIZATION_POLICY, so a partially-valid mapping yields a complete, safe policy that
|
|
1125
|
+
// overlays the DB value via the resolver's `{...dbSettings, ...manifest.settings}` spread. But an invalid
|
|
1126
|
+
// TOP-LEVEL shape (not a mapping at all) is a different case: normalizeCommandAuthorizationPolicy's own
|
|
1127
|
+
// fallback there is meant for callers with no DB value to fall back to, not for this overlay — applying it
|
|
1128
|
+
// here would let a typo'd config silently overwrite a stricter DB-persisted policy with the built-in
|
|
1129
|
+
// default. So only apply the normalized policy when the raw value was actually a mapping; otherwise warn
|
|
1130
|
+
// and leave `out.commandAuthorization` unset so the resolver preserves whatever the DB already has.
|
|
1131
|
+
if (typeof r.commandAuthorization === "object" && r.commandAuthorization !== null && !Array.isArray(r.commandAuthorization)) {
|
|
1132
|
+
const { policy, warnings: commandAuthorizationWarnings } = normalizeCommandAuthorizationPolicy(r.commandAuthorization);
|
|
1133
|
+
warnings.push(...commandAuthorizationWarnings);
|
|
1134
|
+
out.commandAuthorization = policy;
|
|
1135
|
+
}
|
|
1136
|
+
else if (r.commandAuthorization !== undefined) {
|
|
1137
|
+
warnings.push(`Manifest "settings.commandAuthorization" must be an object; ignoring it and keeping any existing policy.`);
|
|
1138
|
+
}
|
|
1139
|
+
// TYPE label category overrides (#priority-linked-issue-gate, #label-modularity): unlike
|
|
1140
|
+
// commandAuthorization/autoMaintain above, this is deliberately kept SPARSE -- only the keys actually
|
|
1141
|
+
// present AND validly-shaped in the raw YAML are copied onto `out.typeLabels` (via
|
|
1142
|
+
// `normalizeTypeLabelSet`, which still fills in the built-in bug/feature/priority keys to run its own
|
|
1143
|
+
// shape checks, but those defaults-filled values are discarded here). A manifest naming only
|
|
1144
|
+
// `typeLabels.priority` must inherit `bug`/`feature` from the DB-persisted value in
|
|
1145
|
+
// `resolveEffectiveSettings`, not have them silently reset to the built-in gittensor:* names -- assigning
|
|
1146
|
+
// the normalizer's complete object here would do exactly that via the resolver's wholesale
|
|
1147
|
+
// `{...dbSettings, ...manifest.settings}` spread. The per-field shape check below (not just "is the key
|
|
1148
|
+
// present") matters too: a malformed value (e.g. `typeLabels.priority: 123`) is present but invalid, so
|
|
1149
|
+
// `normalizeTypeLabelSet` warns and reports its OWN built-in-default fallback for that key -- copying
|
|
1150
|
+
// that fallback into the sparse override would silently overwrite a DB-customized value with the
|
|
1151
|
+
// built-in default on a config typo, instead of leaving the DB value alone. The loop is generic over
|
|
1152
|
+
// whatever keys the raw object actually has (not hardcoded to bug/feature/priority), so an arbitrary
|
|
1153
|
+
// custom category (e.g. `security`) sparse-overrides exactly like a built-in one. The normalizer
|
|
1154
|
+
// enforces the category-count and label-name caps before a sparse key can survive into the override.
|
|
1155
|
+
if (typeof r.typeLabels === "object" && r.typeLabels !== null && !Array.isArray(r.typeLabels)) {
|
|
1156
|
+
const rawTypeLabels = r.typeLabels;
|
|
1157
|
+
if (Object.keys(rawTypeLabels).length === 0) {
|
|
1158
|
+
// A literal `typeLabels: {}` is a DELIBERATE, complete declaration -- "zero configured categories
|
|
1159
|
+
// for this repo" -- distinct from a sparse override whose named keys all failed validation (the
|
|
1160
|
+
// `else` branch below, which must NOT wipe the DB value). Represented as `null` so
|
|
1161
|
+
// `resolveEffectiveSettings` can tell the two apart even though both would otherwise collapse to
|
|
1162
|
+
// the same empty-object shape (#label-modularity).
|
|
1163
|
+
out.typeLabels = null;
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
const validated = normalizeTypeLabelSet(rawTypeLabels, warnings);
|
|
1167
|
+
const isValidLabelName = (value) => typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH;
|
|
1168
|
+
const sparseTypeLabels = {};
|
|
1169
|
+
for (const key of Object.keys(rawTypeLabels)) {
|
|
1170
|
+
if (isValidLabelName(rawTypeLabels[key]) && validated[key] !== undefined)
|
|
1171
|
+
sparseTypeLabels[key] = validated[key];
|
|
1172
|
+
}
|
|
1173
|
+
out.typeLabels = sparseTypeLabels;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
else if (r.typeLabels !== undefined) {
|
|
1177
|
+
warnings.push(`Manifest "settings.typeLabels" must be an object; ignoring it and keeping any existing label names.`);
|
|
1178
|
+
}
|
|
1179
|
+
// Linked-issue label propagation (#priority-linked-issue-gate): same sparse-partial shape as typeLabels
|
|
1180
|
+
// above, for the same reason -- this is the ONLY mechanism that can ever select a maintainer-reward
|
|
1181
|
+
// label like gittensor:priority (never inferred from title/files/AI/PR-labels), so a manifest overriding
|
|
1182
|
+
// just one field (e.g. `enabled`) must not silently reset `mappings` back to the built-in empty default
|
|
1183
|
+
// and discard a DB-configured mapping list. Each field is gated on its OWN raw shape being valid (not
|
|
1184
|
+
// just "is the key present"), for the same reason as typeLabels above -- e.g. a typo'd
|
|
1185
|
+
// `mappings: "oops"` must never silently replace a DB-configured mapping list with the normalizer's
|
|
1186
|
+
// empty-array fallback. A validly-shaped `mappings` array is still a complete replacement when present
|
|
1187
|
+
// (arrays have no per-item precedence semantics here, and any individually-invalid entries inside it
|
|
1188
|
+
// are dropped by the normalizer, not the array itself), matching the array-replace-wholesale overlay
|
|
1189
|
+
// behavior documented for the private-config layer.
|
|
1190
|
+
if (typeof r.linkedIssueLabelPropagation === "object" && r.linkedIssueLabelPropagation !== null && !Array.isArray(r.linkedIssueLabelPropagation)) {
|
|
1191
|
+
const rawPropagation = r.linkedIssueLabelPropagation;
|
|
1192
|
+
const validated = normalizeLinkedIssueLabelPropagationConfig(rawPropagation, warnings);
|
|
1193
|
+
const sparsePropagation = {};
|
|
1194
|
+
if (typeof rawPropagation.enabled === "boolean")
|
|
1195
|
+
sparsePropagation.enabled = validated.enabled;
|
|
1196
|
+
if (typeof rawPropagation.mode === "string" && VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES.includes(rawPropagation.mode)) {
|
|
1197
|
+
sparsePropagation.mode = validated.mode;
|
|
1198
|
+
}
|
|
1199
|
+
if (Array.isArray(rawPropagation.mappings))
|
|
1200
|
+
sparsePropagation.mappings = validated.mappings;
|
|
1201
|
+
out.linkedIssueLabelPropagation = sparsePropagation;
|
|
1202
|
+
}
|
|
1203
|
+
else if (r.linkedIssueLabelPropagation !== undefined) {
|
|
1204
|
+
warnings.push(`Manifest "settings.linkedIssueLabelPropagation" must be an object; ignoring it and keeping any existing policy.`);
|
|
1205
|
+
}
|
|
1206
|
+
// Linked-issue hard rules: same sparse-partial overlay contract as linkedIssueLabelPropagation. A global config
|
|
1207
|
+
// can enable the policy and set label lists; a repo override can toggle one mode without resetting those lists.
|
|
1208
|
+
if (typeof r.linkedIssueHardRules === "object" && r.linkedIssueHardRules !== null && !Array.isArray(r.linkedIssueHardRules)) {
|
|
1209
|
+
const rawRules = r.linkedIssueHardRules;
|
|
1210
|
+
const validated = normalizeLinkedIssueHardRulesConfig(rawRules, warnings);
|
|
1211
|
+
const sparseRules = {};
|
|
1212
|
+
if (isLinkedIssueHardRuleMode(rawRules.ownerAssignedClose))
|
|
1213
|
+
sparseRules.ownerAssignedClose = validated.ownerAssignedClose;
|
|
1214
|
+
if (isLinkedIssueHardRuleMode(rawRules.assignedIssueClose))
|
|
1215
|
+
sparseRules.assignedIssueClose = validated.assignedIssueClose;
|
|
1216
|
+
if (isLinkedIssueHardRuleMode(rawRules.missingPointLabelClose))
|
|
1217
|
+
sparseRules.missingPointLabelClose = validated.missingPointLabelClose;
|
|
1218
|
+
if (isLinkedIssueHardRuleMode(rawRules.maintainerOnlyLabelClose))
|
|
1219
|
+
sparseRules.maintainerOnlyLabelClose = validated.maintainerOnlyLabelClose;
|
|
1220
|
+
if (Array.isArray(rawRules.pointBearingLabels))
|
|
1221
|
+
sparseRules.pointBearingLabels = validated.pointBearingLabels;
|
|
1222
|
+
if (Array.isArray(rawRules.maintainerOnlyLabels))
|
|
1223
|
+
sparseRules.maintainerOnlyLabels = validated.maintainerOnlyLabels;
|
|
1224
|
+
if (typeof rawRules.defaultLabelRepo === "boolean")
|
|
1225
|
+
sparseRules.defaultLabelRepo = validated.defaultLabelRepo;
|
|
1226
|
+
if (typeof rawRules.verifyBeforeClose === "boolean")
|
|
1227
|
+
sparseRules.verifyBeforeClose = validated.verifyBeforeClose;
|
|
1228
|
+
if (typeof rawRules.closeDelaySeconds === "number" && Number.isFinite(rawRules.closeDelaySeconds) && rawRules.closeDelaySeconds >= 0) {
|
|
1229
|
+
sparseRules.closeDelaySeconds = validated.closeDelaySeconds;
|
|
1230
|
+
}
|
|
1231
|
+
out.linkedIssueHardRules = sparseRules;
|
|
1232
|
+
}
|
|
1233
|
+
else if (r.linkedIssueHardRules !== undefined) {
|
|
1234
|
+
warnings.push(`Manifest "settings.linkedIssueHardRules" must be an object; ignoring it and keeping any existing policy.`);
|
|
1235
|
+
}
|
|
1236
|
+
// Unlinked-issue guardrail (#unlinked-issue-guardrail): same sparse-partial overlay contract as
|
|
1237
|
+
// linkedIssueHardRules above -- a repo naming only `mode` must not silently reset `minConfidence` back to
|
|
1238
|
+
// the built-in default.
|
|
1239
|
+
if (typeof r.unlinkedIssueGuardrail === "object" && r.unlinkedIssueGuardrail !== null && !Array.isArray(r.unlinkedIssueGuardrail)) {
|
|
1240
|
+
const rawGuardrail = r.unlinkedIssueGuardrail;
|
|
1241
|
+
const validated = normalizeUnlinkedIssueGuardrailConfig(rawGuardrail, warnings);
|
|
1242
|
+
const sparseGuardrail = {};
|
|
1243
|
+
if (isUnlinkedIssueGuardrailMode(rawGuardrail.mode))
|
|
1244
|
+
sparseGuardrail.mode = validated.mode;
|
|
1245
|
+
if (typeof rawGuardrail.minConfidence === "number" && Number.isFinite(rawGuardrail.minConfidence) && rawGuardrail.minConfidence >= 0 && rawGuardrail.minConfidence <= 1) {
|
|
1246
|
+
sparseGuardrail.minConfidence = validated.minConfidence;
|
|
1247
|
+
}
|
|
1248
|
+
out.unlinkedIssueGuardrail = sparseGuardrail;
|
|
1249
|
+
}
|
|
1250
|
+
else if (r.unlinkedIssueGuardrail !== undefined) {
|
|
1251
|
+
warnings.push(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`);
|
|
1252
|
+
}
|
|
1253
|
+
// Screenshot-table gate (#2006): same sparse-partial overlay contract as unlinkedIssueGuardrail above -- a
|
|
1254
|
+
// repo naming only `enabled` must not silently reset `whenLabels`/`whenPaths`/`action`/`message`.
|
|
1255
|
+
if (typeof r.screenshotTableGate === "object" && r.screenshotTableGate !== null && !Array.isArray(r.screenshotTableGate)) {
|
|
1256
|
+
const rawGate = r.screenshotTableGate;
|
|
1257
|
+
const validated = normalizeScreenshotTableGateConfig(rawGate, warnings);
|
|
1258
|
+
const sparseGate = {};
|
|
1259
|
+
if (typeof rawGate.enabled === "boolean")
|
|
1260
|
+
sparseGate.enabled = validated.enabled;
|
|
1261
|
+
if (Array.isArray(rawGate.whenLabels))
|
|
1262
|
+
sparseGate.whenLabels = validated.whenLabels;
|
|
1263
|
+
if (Array.isArray(rawGate.whenPaths))
|
|
1264
|
+
sparseGate.whenPaths = validated.whenPaths;
|
|
1265
|
+
if (isScreenshotTableGateAction(rawGate.action))
|
|
1266
|
+
sparseGate.action = validated.action;
|
|
1267
|
+
if (typeof rawGate.message === "string" && rawGate.message.trim().length > 0)
|
|
1268
|
+
sparseGate.message = validated.message;
|
|
1269
|
+
if (Array.isArray(rawGate.requireViewports))
|
|
1270
|
+
sparseGate.requireViewports = validated.requireViewports;
|
|
1271
|
+
if (Array.isArray(rawGate.requireThemes))
|
|
1272
|
+
sparseGate.requireThemes = validated.requireThemes;
|
|
1273
|
+
if (typeof rawGate.skillFileUrl === "string" && rawGate.skillFileUrl.trim().length > 0)
|
|
1274
|
+
sparseGate.skillFileUrl = validated.skillFileUrl;
|
|
1275
|
+
out.screenshotTableGate = sparseGate;
|
|
1276
|
+
}
|
|
1277
|
+
else if (r.screenshotTableGate !== undefined) {
|
|
1278
|
+
warnings.push(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`);
|
|
1279
|
+
}
|
|
1280
|
+
// Advisory-AI routing (#4364): same sparse-partial overlay contract as screenshotTableGate above -- a repo
|
|
1281
|
+
// naming only `slop` must not silently reset `e2eTestGen`/`planner`/`summaries` back to their defaults.
|
|
1282
|
+
if (typeof r.advisoryAiRouting === "object" && r.advisoryAiRouting !== null && !Array.isArray(r.advisoryAiRouting)) {
|
|
1283
|
+
const rawRouting = r.advisoryAiRouting;
|
|
1284
|
+
const validated = normalizeAdvisoryAiRoutingConfig(rawRouting, warnings);
|
|
1285
|
+
const sparseRouting = {};
|
|
1286
|
+
if (typeof rawRouting.slop === "boolean")
|
|
1287
|
+
sparseRouting.slop = validated.slop;
|
|
1288
|
+
if (typeof rawRouting.e2eTestGen === "boolean")
|
|
1289
|
+
sparseRouting.e2eTestGen = validated.e2eTestGen;
|
|
1290
|
+
if (typeof rawRouting.planner === "boolean")
|
|
1291
|
+
sparseRouting.planner = validated.planner;
|
|
1292
|
+
if (typeof rawRouting.summaries === "boolean")
|
|
1293
|
+
sparseRouting.summaries = validated.summaries;
|
|
1294
|
+
if (typeof rawRouting.chatQa === "boolean")
|
|
1295
|
+
sparseRouting.chatQa = validated.chatQa;
|
|
1296
|
+
if (typeof rawRouting.chatQaFrontierFallback === "boolean")
|
|
1297
|
+
sparseRouting.chatQaFrontierFallback = validated.chatQaFrontierFallback;
|
|
1298
|
+
if (typeof rawRouting.intentRouting === "boolean")
|
|
1299
|
+
sparseRouting.intentRouting = validated.intentRouting;
|
|
1300
|
+
out.advisoryAiRouting = sparseRouting;
|
|
1301
|
+
}
|
|
1302
|
+
else if (r.advisoryAiRouting !== undefined) {
|
|
1303
|
+
warnings.push(`Manifest "settings.advisoryAiRouting" must be an object; ignoring it and keeping any existing policy.`);
|
|
1304
|
+
}
|
|
1305
|
+
// Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it
|
|
1306
|
+
// when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured
|
|
1307
|
+
// list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in.
|
|
1308
|
+
if (r.contributorBlacklist !== undefined) {
|
|
1309
|
+
const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist);
|
|
1310
|
+
warnings.push(...blacklistWarnings);
|
|
1311
|
+
if (entries.length > 0)
|
|
1312
|
+
out.contributorBlacklist = entries;
|
|
1313
|
+
}
|
|
1314
|
+
// Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer
|
|
1315
|
+
// shape as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning
|
|
1316
|
+
// instead of configuring a nonsensical cap. Valid counts clamp to the fixed live-verification budget. UNLIKE
|
|
1317
|
+
// contributorBlacklist above, an explicit yml `null` here is
|
|
1318
|
+
// load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a
|
|
1319
|
+
// maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting
|
|
1320
|
+
// the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null`
|
|
1321
|
+
// return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null`
|
|
1322
|
+
// (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an
|
|
1323
|
+
// invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset.
|
|
1324
|
+
if (r.contributorOpenPrCap === null) {
|
|
1325
|
+
out.contributorOpenPrCap = null;
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
const contributorOpenPrCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings);
|
|
1329
|
+
if (contributorOpenPrCap !== null)
|
|
1330
|
+
out.contributorOpenPrCap = contributorOpenPrCap;
|
|
1331
|
+
}
|
|
1332
|
+
if (r.contributorOpenIssueCap === null) {
|
|
1333
|
+
out.contributorOpenIssueCap = null;
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
const contributorOpenIssueCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings);
|
|
1337
|
+
if (contributorOpenIssueCap !== null)
|
|
1338
|
+
out.contributorOpenIssueCap = contributorOpenIssueCap;
|
|
1339
|
+
}
|
|
1340
|
+
// #label-scoping: same load-bearing-null idiom as blacklistLabel above.
|
|
1341
|
+
if (r.contributorCapLabel === null) {
|
|
1342
|
+
out.contributorCapLabel = null;
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings);
|
|
1346
|
+
if (contributorCapLabel !== null)
|
|
1347
|
+
out.contributorCapLabel = contributorCapLabel;
|
|
1348
|
+
}
|
|
1349
|
+
// CI-run cancellation on a contributor_cap close (#2462): an explicit yml `null` is load-bearing (clears a
|
|
1350
|
+
// DB-configured value back to "unset", falling through to the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var),
|
|
1351
|
+
// matching contributorOpenPrCap's own null-vs-omitted distinction above.
|
|
1352
|
+
if (r.contributorCapCancelCi === null) {
|
|
1353
|
+
out.contributorCapCancelCi = null;
|
|
1354
|
+
}
|
|
1355
|
+
else {
|
|
1356
|
+
const contributorCapCancelCi = normalizeOptionalBoolean(r.contributorCapCancelCi, "settings.contributorCapCancelCi", warnings);
|
|
1357
|
+
if (contributorCapCancelCi !== null)
|
|
1358
|
+
out.contributorCapCancelCi = contributorCapCancelCi;
|
|
1359
|
+
}
|
|
1360
|
+
// Review-request nagging cooldown (#2463): throttle a contributor repeatedly pinging @gittensory for review.
|
|
1361
|
+
const reviewNagPolicy = normalizeOptionalEnum(r.reviewNagPolicy, "settings.reviewNagPolicy", ["off", "hold", "close"], warnings);
|
|
1362
|
+
if (reviewNagPolicy !== null)
|
|
1363
|
+
out.reviewNagPolicy = reviewNagPolicy;
|
|
1364
|
+
const reviewNagMaxPings = normalizeOptionalPositiveInteger(r.reviewNagMaxPings, "settings.reviewNagMaxPings", warnings);
|
|
1365
|
+
if (reviewNagMaxPings !== null)
|
|
1366
|
+
out.reviewNagMaxPings = reviewNagMaxPings;
|
|
1367
|
+
const reviewNagCooldownDays = normalizeOptionalPositiveInteger(r.reviewNagCooldownDays, "settings.reviewNagCooldownDays", warnings);
|
|
1368
|
+
if (reviewNagCooldownDays !== null && reviewNagCooldownDays <= MAX_REVIEW_NAG_COOLDOWN_DAYS)
|
|
1369
|
+
out.reviewNagCooldownDays = reviewNagCooldownDays;
|
|
1370
|
+
if (reviewNagCooldownDays !== null && reviewNagCooldownDays > MAX_REVIEW_NAG_COOLDOWN_DAYS) {
|
|
1371
|
+
warnings.push(`Manifest field "settings.reviewNagCooldownDays" must be at most ${MAX_REVIEW_NAG_COOLDOWN_DAYS}; ignoring it.`);
|
|
1372
|
+
}
|
|
1373
|
+
// #label-scoping: same load-bearing-null idiom as blacklistLabel above.
|
|
1374
|
+
if (r.reviewNagLabel === null) {
|
|
1375
|
+
out.reviewNagLabel = null;
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
const reviewNagLabel = normalizeOptionalString(r.reviewNagLabel, "settings.reviewNagLabel", warnings);
|
|
1379
|
+
if (reviewNagLabel !== null)
|
|
1380
|
+
out.reviewNagLabel = reviewNagLabel;
|
|
1381
|
+
}
|
|
1382
|
+
// Maintainer-mention nag moderation (#label-scoping): GitHub logins ALSO throttled under the review-nag
|
|
1383
|
+
// cooldown above, on top of the bot's own @gittensory handle. Only set it when at least one VALID login
|
|
1384
|
+
// survives normalization, so a malformed block never blanks the DB-configured list via the resolver's
|
|
1385
|
+
// `{...dbSettings, ...manifest.settings}` overlay (same reasoning as autoCloseExemptLogins below).
|
|
1386
|
+
if (r.reviewNagMonitoredMentions !== undefined) {
|
|
1387
|
+
const { logins: monitoredMentions, warnings: monitoredMentionWarnings } = normalizeAutoCloseExemptLogins(r.reviewNagMonitoredMentions);
|
|
1388
|
+
warnings.push(...monitoredMentionWarnings);
|
|
1389
|
+
if (monitoredMentions.length > 0)
|
|
1390
|
+
out.reviewNagMonitoredMentions = monitoredMentions;
|
|
1391
|
+
}
|
|
1392
|
+
// Shared repo-scoped exemption list (#2463): only set it when at least one VALID login survives
|
|
1393
|
+
// normalization, so a malformed block never blanks the DB-configured list via the resolver's overlay.
|
|
1394
|
+
if (r.autoCloseExemptLogins !== undefined) {
|
|
1395
|
+
const { logins, warnings: exemptWarnings } = normalizeAutoCloseExemptLogins(r.autoCloseExemptLogins);
|
|
1396
|
+
warnings.push(...exemptWarnings);
|
|
1397
|
+
if (logins.length > 0)
|
|
1398
|
+
out.autoCloseExemptLogins = logins;
|
|
1399
|
+
}
|
|
1400
|
+
// Hard manual-review guardrails are config-as-code only. Arrays replace lower layers wholesale, so only an
|
|
1401
|
+
// explicit [] or a non-empty valid list replaces a private global setting. Null/malformed values are ignored
|
|
1402
|
+
// instead of clearing.
|
|
1403
|
+
if (Array.isArray(r.hardGuardrailGlobs)) {
|
|
1404
|
+
const hardGuardrailGlobs = normalizeStringList(r.hardGuardrailGlobs, "settings.hardGuardrailGlobs", warnings);
|
|
1405
|
+
if (r.hardGuardrailGlobs.length === 0 || hardGuardrailGlobs.length > 0) {
|
|
1406
|
+
out.hardGuardrailGlobs = hardGuardrailGlobs;
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
warnings.push(`Manifest "settings.hardGuardrailGlobs" did not contain any valid path globs; ignoring it and keeping any existing guardrails.`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else if (r.hardGuardrailGlobs !== undefined) {
|
|
1413
|
+
warnings.push(`Manifest "settings.hardGuardrailGlobs" must be an array of path globs; ignoring it and keeping any existing guardrails.`);
|
|
1414
|
+
}
|
|
1415
|
+
// Manual-review label is deliberately separate from review_state_label so operators can use one hold label
|
|
1416
|
+
// without enabling the old ready/changes disposition labels. Null disables only the label, not the hold.
|
|
1417
|
+
if (r.manualReviewLabel === null) {
|
|
1418
|
+
out.manualReviewLabel = null;
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
const manualReviewLabel = normalizeOptionalString(r.manualReviewLabel, "settings.manualReviewLabel", warnings);
|
|
1422
|
+
if (manualReviewLabel !== null)
|
|
1423
|
+
out.manualReviewLabel = manualReviewLabel;
|
|
1424
|
+
}
|
|
1425
|
+
if (r.readyToMergeLabel === null) {
|
|
1426
|
+
out.readyToMergeLabel = null;
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
const readyToMergeLabel = normalizeOptionalString(r.readyToMergeLabel, "settings.readyToMergeLabel", warnings);
|
|
1430
|
+
if (readyToMergeLabel !== null)
|
|
1431
|
+
out.readyToMergeLabel = readyToMergeLabel;
|
|
1432
|
+
}
|
|
1433
|
+
if (r.changesRequestedLabel === null) {
|
|
1434
|
+
out.changesRequestedLabel = null;
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
const changesRequestedLabel = normalizeOptionalString(r.changesRequestedLabel, "settings.changesRequestedLabel", warnings);
|
|
1438
|
+
if (changesRequestedLabel !== null)
|
|
1439
|
+
out.changesRequestedLabel = changesRequestedLabel;
|
|
1440
|
+
}
|
|
1441
|
+
if (r.migrationCollisionLabel === null) {
|
|
1442
|
+
out.migrationCollisionLabel = null;
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
const migrationCollisionLabel = normalizeOptionalString(r.migrationCollisionLabel, "settings.migrationCollisionLabel", warnings);
|
|
1446
|
+
if (migrationCollisionLabel !== null)
|
|
1447
|
+
out.migrationCollisionLabel = migrationCollisionLabel;
|
|
1448
|
+
}
|
|
1449
|
+
if (r.pendingClosureLabel === null) {
|
|
1450
|
+
out.pendingClosureLabel = null;
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
const pendingClosureLabel = normalizeOptionalString(r.pendingClosureLabel, "settings.pendingClosureLabel", warnings);
|
|
1454
|
+
if (pendingClosureLabel !== null)
|
|
1455
|
+
out.pendingClosureLabel = pendingClosureLabel;
|
|
1456
|
+
}
|
|
1457
|
+
// Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold
|
|
1458
|
+
// back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above.
|
|
1459
|
+
if (r.accountAgeThresholdDays === null) {
|
|
1460
|
+
out.accountAgeThresholdDays = null;
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings);
|
|
1464
|
+
if (accountAgeThresholdDays !== null)
|
|
1465
|
+
out.accountAgeThresholdDays = accountAgeThresholdDays;
|
|
1466
|
+
}
|
|
1467
|
+
const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings);
|
|
1468
|
+
if (newAccountLabel !== null)
|
|
1469
|
+
out.newAccountLabel = newAccountLabel;
|
|
1470
|
+
// Per-command @gittensory rate limit (#2560): generalizes review-nag's cooldown pattern to every command.
|
|
1471
|
+
const commandRateLimitPolicy = normalizeOptionalEnum(r.commandRateLimitPolicy, "settings.commandRateLimitPolicy", ["off", "hold"], warnings);
|
|
1472
|
+
if (commandRateLimitPolicy !== null)
|
|
1473
|
+
out.commandRateLimitPolicy = commandRateLimitPolicy;
|
|
1474
|
+
const commandRateLimitMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitMaxPerWindow, "settings.commandRateLimitMaxPerWindow", warnings);
|
|
1475
|
+
if (commandRateLimitMaxPerWindow !== null)
|
|
1476
|
+
out.commandRateLimitMaxPerWindow = commandRateLimitMaxPerWindow;
|
|
1477
|
+
const commandRateLimitAiMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitAiMaxPerWindow, "settings.commandRateLimitAiMaxPerWindow", warnings);
|
|
1478
|
+
if (commandRateLimitAiMaxPerWindow !== null)
|
|
1479
|
+
out.commandRateLimitAiMaxPerWindow = commandRateLimitAiMaxPerWindow;
|
|
1480
|
+
const commandRateLimitWindowHours = normalizeOptionalPositiveInteger(r.commandRateLimitWindowHours, "settings.commandRateLimitWindowHours", warnings);
|
|
1481
|
+
if (commandRateLimitWindowHours !== null)
|
|
1482
|
+
out.commandRateLimitWindowHours = commandRateLimitWindowHours;
|
|
1483
|
+
// Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config.
|
|
1484
|
+
const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"], warnings);
|
|
1485
|
+
if (moderationGateMode !== null)
|
|
1486
|
+
out.moderationGateMode = moderationGateMode;
|
|
1487
|
+
// #gate-flagged: normalizeModerationRules returns an EMPTY rules array for two semantically different
|
|
1488
|
+
// inputs -- a genuinely empty yml list (`moderationRules: []`, an intentional "opt every rule out for this
|
|
1489
|
+
// repo") and a MALFORMED one (a non-array, or an array where every entry fails validation) that degrades to
|
|
1490
|
+
// empty as its safe fallback. Applying the malformed case as an override would silently disable every rule
|
|
1491
|
+
// for this repo instead of leaving the DB-configured value intact, so the two must be told apart by the RAW
|
|
1492
|
+
// input's own shape -- not just the normalized result -- before assigning. A PARTIAL list (some valid, some
|
|
1493
|
+
// invalid entries) still applies the surviving valid subset, mirroring autoCloseExemptLogins' behavior.
|
|
1494
|
+
if (r.moderationRules !== undefined) {
|
|
1495
|
+
const { rules, warnings: moderationRuleWarnings } = normalizeModerationRules(r.moderationRules);
|
|
1496
|
+
warnings.push(...moderationRuleWarnings);
|
|
1497
|
+
const intentionalEmptyList = Array.isArray(r.moderationRules) && r.moderationRules.length === 0;
|
|
1498
|
+
if (rules.length > 0 || intentionalEmptyList)
|
|
1499
|
+
out.moderationRules = rules;
|
|
1500
|
+
}
|
|
1501
|
+
const moderationWarningLabel = normalizeModerationLabel(r.moderationWarningLabel);
|
|
1502
|
+
if (moderationWarningLabel !== undefined)
|
|
1503
|
+
out.moderationWarningLabel = moderationWarningLabel;
|
|
1504
|
+
const moderationBannedLabel = normalizeModerationLabel(r.moderationBannedLabel);
|
|
1505
|
+
if (moderationBannedLabel !== undefined)
|
|
1506
|
+
out.moderationBannedLabel = moderationBannedLabel;
|
|
1507
|
+
// Review-evasion protection (#review-evasion-protection): a contributor closing/converting-to-draft their
|
|
1508
|
+
// own PR while gittensory has an active review pass running is dodging the one-shot review.
|
|
1509
|
+
const reviewEvasionProtection = normalizeOptionalEnum(r.reviewEvasionProtection, "settings.reviewEvasionProtection", ["off", "close"], warnings);
|
|
1510
|
+
if (reviewEvasionProtection !== null)
|
|
1511
|
+
out.reviewEvasionProtection = reviewEvasionProtection;
|
|
1512
|
+
// #label-scoping: same load-bearing-null idiom as blacklistLabel above.
|
|
1513
|
+
if (r.reviewEvasionLabel === null) {
|
|
1514
|
+
out.reviewEvasionLabel = null;
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
const reviewEvasionLabel = normalizeOptionalString(r.reviewEvasionLabel, "settings.reviewEvasionLabel", warnings);
|
|
1518
|
+
if (reviewEvasionLabel !== null)
|
|
1519
|
+
out.reviewEvasionLabel = reviewEvasionLabel;
|
|
1520
|
+
}
|
|
1521
|
+
const reviewEvasionComment = normalizeOptionalBoolean(r.reviewEvasionComment, "settings.reviewEvasionComment", warnings);
|
|
1522
|
+
if (reviewEvasionComment !== null)
|
|
1523
|
+
out.reviewEvasionComment = reviewEvasionComment;
|
|
1524
|
+
const mergeTrainMode = normalizeOptionalEnum(r.mergeTrainMode, "settings.mergeTrainMode", ["off", "audit", "enforce"], warnings);
|
|
1525
|
+
if (mergeTrainMode !== null)
|
|
1526
|
+
out.mergeTrainMode = mergeTrainMode;
|
|
1527
|
+
return out;
|
|
1528
|
+
}
|
|
1529
|
+
/** Serialize the settings override for the cache round-trip; returns null when nothing is set. */
|
|
1530
|
+
export function settingsOverrideToJson(settings) {
|
|
1531
|
+
if (Object.keys(settings).length === 0)
|
|
1532
|
+
return null;
|
|
1533
|
+
return { ...settings };
|
|
1534
|
+
}
|
|
1535
|
+
/** A bounded, PUBLIC-SAFE maintainer string (footer/note). Trimmed, length-capped, and rejected with a
|
|
1536
|
+
* warning if it contains any forbidden public term — it is then dropped, never published. */
|
|
1537
|
+
function parsePublicSafeText(value, field, warnings) {
|
|
1538
|
+
const text = normalizeOptionalString(value, field, warnings);
|
|
1539
|
+
if (text === null)
|
|
1540
|
+
return null;
|
|
1541
|
+
const bounded = text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH) : text;
|
|
1542
|
+
if (!isFocusManifestPublicSafe(bounded)) {
|
|
1543
|
+
warnings.push(`Manifest "${field}" contains content that is not public-safe; ignoring it.`);
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
return bounded;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Parse the optional `review:` block — maintainer overrides for the public review-panel content. Never
|
|
1550
|
+
* throws; invalid/unsafe values are dropped with warnings.
|
|
1551
|
+
*/
|
|
1552
|
+
function parseReviewConfig(value, warnings) {
|
|
1553
|
+
const empty = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null };
|
|
1554
|
+
if (value === undefined || value === null)
|
|
1555
|
+
return empty;
|
|
1556
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1557
|
+
warnings.push(`Manifest field "review" must be a mapping; ignoring it.`);
|
|
1558
|
+
return empty;
|
|
1559
|
+
}
|
|
1560
|
+
const r = value;
|
|
1561
|
+
const footerRecord = r.footer !== null && typeof r.footer === "object" && !Array.isArray(r.footer) ? r.footer : undefined;
|
|
1562
|
+
if (r.footer !== undefined && r.footer !== null && footerRecord === undefined)
|
|
1563
|
+
warnings.push(`Manifest "review.footer" must be a mapping; ignoring it.`);
|
|
1564
|
+
const fieldsRecord = r.fields !== null && typeof r.fields === "object" && !Array.isArray(r.fields) ? r.fields : undefined;
|
|
1565
|
+
if (r.fields !== undefined && r.fields !== null && fieldsRecord === undefined)
|
|
1566
|
+
warnings.push(`Manifest "review.fields" must be a mapping; ignoring it.`);
|
|
1567
|
+
const fields = {};
|
|
1568
|
+
if (fieldsRecord) {
|
|
1569
|
+
for (const key of REVIEW_FIELD_KEYS) {
|
|
1570
|
+
const flag = normalizeOptionalBoolean(fieldsRecord[key], `review.fields.${key}`, warnings);
|
|
1571
|
+
if (flag !== null)
|
|
1572
|
+
fields[key] = flag;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const enrichmentRecord = r.enrichment !== null && typeof r.enrichment === "object" && !Array.isArray(r.enrichment) ? r.enrichment : undefined;
|
|
1576
|
+
if (r.enrichment !== undefined && r.enrichment !== null && enrichmentRecord === undefined)
|
|
1577
|
+
warnings.push(`Manifest "review.enrichment" must be a mapping; ignoring it.`);
|
|
1578
|
+
const enrichmentAnalyzers = {};
|
|
1579
|
+
if (enrichmentRecord) {
|
|
1580
|
+
for (const key of Object.keys(enrichmentRecord)) {
|
|
1581
|
+
if (!REES_ANALYZER_NAME_SET.has(key)) {
|
|
1582
|
+
warnings.push(`Manifest "review.enrichment" has unknown analyzer "${key}"; ignoring it.`);
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
const flag = normalizeOptionalBoolean(enrichmentRecord[key], `review.enrichment.${key}`, warnings);
|
|
1586
|
+
if (flag !== null)
|
|
1587
|
+
enrichmentAnalyzers[key] = flag;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null;
|
|
1591
|
+
const note = parsePublicSafeText(r.note, "review.note", warnings);
|
|
1592
|
+
const profile = parseReviewProfile(r.profile, warnings);
|
|
1593
|
+
const tone = parsePublicSafeText(r.tone, "review.tone", warnings);
|
|
1594
|
+
const securityFocus = normalizeOptionalBoolean(r.security_focus, "review.security_focus", warnings);
|
|
1595
|
+
const inlineComments = normalizeOptionalBoolean(r.inline_comments, "review.inline_comments", warnings);
|
|
1596
|
+
const fixHandoff = normalizeOptionalBoolean(r.fixHandoff, "review.fixHandoff", warnings);
|
|
1597
|
+
const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings);
|
|
1598
|
+
const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings);
|
|
1599
|
+
const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings);
|
|
1600
|
+
const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings);
|
|
1601
|
+
const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings);
|
|
1602
|
+
const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", warnings);
|
|
1603
|
+
const selftune = normalizeOptionalBoolean(r.selftune, "review.selftune", warnings);
|
|
1604
|
+
const reviewMemory = normalizeOptionalBoolean(r.memory, "review.memory", warnings);
|
|
1605
|
+
const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings);
|
|
1606
|
+
const inlineCommentsPerCategory = normalizeOptionalNonNegativeInt(r.inline_comments_per_category, "review.inline_comments_per_category", warnings);
|
|
1607
|
+
const minFindingSeverity = normalizeOptionalEnum(r.min_finding_severity, "review.min_finding_severity", REVIEW_FINDING_SEVERITY_LADDER, warnings);
|
|
1608
|
+
const maxFindings = parseMaxFindingsConfig(r.max_findings, warnings);
|
|
1609
|
+
const commentVerbosity = normalizeOptionalEnum(r.comment_verbosity, "review.comment_verbosity", COMMENT_VERBOSITY_LEVELS, warnings);
|
|
1610
|
+
const e2eTestDelivery = normalizeOptionalEnum(r.e2e_test_delivery, "review.e2e_test_delivery", E2E_TEST_DELIVERY_MODES, warnings);
|
|
1611
|
+
const e2eTestAutoTrigger = normalizeOptionalBoolean(r.e2e_test_auto_trigger, "review.e2e_test_auto_trigger", warnings);
|
|
1612
|
+
const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings);
|
|
1613
|
+
const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings);
|
|
1614
|
+
const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings);
|
|
1615
|
+
const pathFilters = parseReviewPathFilters(r.path_filters, warnings);
|
|
1616
|
+
const preMergeChecks = parseReviewPreMergeChecks(r.pre_merge_checks, warnings);
|
|
1617
|
+
const autoReview = parseAutoReviewConfig(r.auto_review, warnings);
|
|
1618
|
+
const aiModel = parseSelfHostAiModelConfig(r.ai_model, warnings);
|
|
1619
|
+
const visual = parseVisualConfig(r.visual, warnings);
|
|
1620
|
+
const linkedIssueSatisfaction = normalizeOptionalEnum(r.linkedIssueSatisfaction, "review.linkedIssueSatisfaction", LINKED_ISSUE_SATISFACTION_MODES, warnings);
|
|
1621
|
+
return {
|
|
1622
|
+
present: footerText !== null ||
|
|
1623
|
+
note !== null ||
|
|
1624
|
+
profile !== null ||
|
|
1625
|
+
tone !== null ||
|
|
1626
|
+
securityFocus !== null ||
|
|
1627
|
+
inlineComments !== null ||
|
|
1628
|
+
fixHandoff !== null ||
|
|
1629
|
+
autoMergeSummary !== null ||
|
|
1630
|
+
suggestions !== null ||
|
|
1631
|
+
changedFilesSummary !== null ||
|
|
1632
|
+
effortScore !== null ||
|
|
1633
|
+
impactMap !== null ||
|
|
1634
|
+
cultureProfile !== null ||
|
|
1635
|
+
selftune !== null ||
|
|
1636
|
+
reviewMemory !== null ||
|
|
1637
|
+
findingCategories !== null ||
|
|
1638
|
+
inlineCommentsPerCategory !== null ||
|
|
1639
|
+
minFindingSeverity !== null ||
|
|
1640
|
+
maxFindingsPresent(maxFindings) ||
|
|
1641
|
+
commentVerbosity !== null ||
|
|
1642
|
+
e2eTestDelivery !== null ||
|
|
1643
|
+
e2eTestAutoTrigger !== null ||
|
|
1644
|
+
pathInstructions.length > 0 ||
|
|
1645
|
+
instructions !== null ||
|
|
1646
|
+
excludePaths.length > 0 ||
|
|
1647
|
+
pathFilters.length > 0 ||
|
|
1648
|
+
preMergeChecks.length > 0 ||
|
|
1649
|
+
autoReviewPresent(autoReview) ||
|
|
1650
|
+
selfHostAiModelPresent(aiModel) ||
|
|
1651
|
+
visualConfigPresent(visual) ||
|
|
1652
|
+
linkedIssueSatisfaction !== null ||
|
|
1653
|
+
Object.keys(fields).length > 0 ||
|
|
1654
|
+
Object.keys(enrichmentAnalyzers).length > 0,
|
|
1655
|
+
footerText,
|
|
1656
|
+
note,
|
|
1657
|
+
fields,
|
|
1658
|
+
autoReview,
|
|
1659
|
+
aiModel,
|
|
1660
|
+
visual,
|
|
1661
|
+
linkedIssueSatisfaction,
|
|
1662
|
+
enrichmentAnalyzers,
|
|
1663
|
+
profile,
|
|
1664
|
+
tone,
|
|
1665
|
+
securityFocus,
|
|
1666
|
+
inlineComments,
|
|
1667
|
+
fixHandoff,
|
|
1668
|
+
autoMergeSummary,
|
|
1669
|
+
suggestions,
|
|
1670
|
+
changedFilesSummary,
|
|
1671
|
+
effortScore,
|
|
1672
|
+
impactMap,
|
|
1673
|
+
cultureProfile,
|
|
1674
|
+
selftune,
|
|
1675
|
+
reviewMemory,
|
|
1676
|
+
findingCategories,
|
|
1677
|
+
inlineCommentsPerCategory,
|
|
1678
|
+
minFindingSeverity,
|
|
1679
|
+
maxFindings,
|
|
1680
|
+
commentVerbosity,
|
|
1681
|
+
e2eTestDelivery,
|
|
1682
|
+
e2eTestAutoTrigger,
|
|
1683
|
+
pathInstructions,
|
|
1684
|
+
instructions,
|
|
1685
|
+
excludePaths,
|
|
1686
|
+
pathFilters,
|
|
1687
|
+
preMergeChecks,
|
|
1688
|
+
sharedConfigSource: null,
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
function pickOverlayNullable(override, base) {
|
|
1692
|
+
return override !== null ? override : base;
|
|
1693
|
+
}
|
|
1694
|
+
function pickOverlayStringList(override, base) {
|
|
1695
|
+
return override.length > 0 ? [...override] : [...base];
|
|
1696
|
+
}
|
|
1697
|
+
function pickOverlayPartialRecord(override, base) {
|
|
1698
|
+
return { ...base, ...override };
|
|
1699
|
+
}
|
|
1700
|
+
function overlayMaxFindingsConfig(base, override) {
|
|
1701
|
+
return {
|
|
1702
|
+
blockers: pickOverlayNullable(override.blockers, base.blockers),
|
|
1703
|
+
nits: pickOverlayNullable(override.nits, base.nits),
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
function overlayAutoReviewConfig(base, override) {
|
|
1707
|
+
return {
|
|
1708
|
+
skipDrafts: pickOverlayNullable(override.skipDrafts, base.skipDrafts),
|
|
1709
|
+
cadence: pickOverlayNullable(override.cadence, base.cadence),
|
|
1710
|
+
ignoreAuthors: pickOverlayStringList(override.ignoreAuthors, base.ignoreAuthors),
|
|
1711
|
+
ignoreTitleKeywords: pickOverlayStringList(override.ignoreTitleKeywords, base.ignoreTitleKeywords),
|
|
1712
|
+
skipLabels: pickOverlayStringList(override.skipLabels, base.skipLabels),
|
|
1713
|
+
skipDocsOnly: pickOverlayNullable(override.skipDocsOnly, base.skipDocsOnly),
|
|
1714
|
+
maxAddedLines: override.maxAddedLines > 0 ? override.maxAddedLines : base.maxAddedLines,
|
|
1715
|
+
maxFiles: override.maxFiles > 0 ? override.maxFiles : base.maxFiles,
|
|
1716
|
+
baseBranches: pickOverlayStringList(override.baseBranches, base.baseBranches),
|
|
1717
|
+
autoPauseAfterReviewedCommits: pickOverlayNullable(override.autoPauseAfterReviewedCommits, base.autoPauseAfterReviewedCommits),
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
function overlaySelfHostAiModelConfig(base, override) {
|
|
1721
|
+
return {
|
|
1722
|
+
claudeModel: pickOverlayNullable(override.claudeModel, base.claudeModel),
|
|
1723
|
+
claudeEffort: pickOverlayNullable(override.claudeEffort, base.claudeEffort),
|
|
1724
|
+
codexModel: pickOverlayNullable(override.codexModel, base.codexModel),
|
|
1725
|
+
codexEffort: pickOverlayNullable(override.codexEffort, base.codexEffort),
|
|
1726
|
+
ollamaModel: pickOverlayNullable(override.ollamaModel, base.ollamaModel),
|
|
1727
|
+
openaiModel: pickOverlayNullable(override.openaiModel, base.openaiModel),
|
|
1728
|
+
openaiCompatibleModel: pickOverlayNullable(override.openaiCompatibleModel, base.openaiCompatibleModel),
|
|
1729
|
+
anthropicModel: pickOverlayNullable(override.anthropicModel, base.anthropicModel),
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
function overlayVisualConfig(base, override) {
|
|
1733
|
+
return {
|
|
1734
|
+
productionUrl: pickOverlayNullable(override.productionUrl, base.productionUrl),
|
|
1735
|
+
preview: { urlTemplate: pickOverlayNullable(override.preview.urlTemplate, base.preview.urlTemplate) },
|
|
1736
|
+
routes: {
|
|
1737
|
+
paths: pickOverlayStringList(override.routes.paths, base.routes.paths),
|
|
1738
|
+
maxRoutes: pickOverlayNullable(override.routes.maxRoutes, base.routes.maxRoutes),
|
|
1739
|
+
},
|
|
1740
|
+
themes: override.themes.length > 0 ? [...override.themes] : [...base.themes],
|
|
1741
|
+
gif: override.gif ? override.gif : base.gif,
|
|
1742
|
+
enabled: pickOverlayNullable(override.enabled, base.enabled),
|
|
1743
|
+
themeStorageKey: pickOverlayNullable(override.themeStorageKey, base.themeStorageKey),
|
|
1744
|
+
actionsFallback: override.actionsFallback ? override.actionsFallback : base.actionsFallback,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
function computeReviewConfigPresent(review) {
|
|
1748
|
+
return (review.footerText !== null ||
|
|
1749
|
+
review.note !== null ||
|
|
1750
|
+
review.profile !== null ||
|
|
1751
|
+
review.tone !== null ||
|
|
1752
|
+
review.securityFocus !== null ||
|
|
1753
|
+
review.inlineComments !== null ||
|
|
1754
|
+
review.fixHandoff !== null ||
|
|
1755
|
+
review.autoMergeSummary !== null ||
|
|
1756
|
+
review.suggestions !== null ||
|
|
1757
|
+
review.changedFilesSummary !== null ||
|
|
1758
|
+
review.effortScore !== null ||
|
|
1759
|
+
review.impactMap !== null ||
|
|
1760
|
+
review.cultureProfile !== null ||
|
|
1761
|
+
review.selftune !== null ||
|
|
1762
|
+
review.reviewMemory !== null ||
|
|
1763
|
+
review.findingCategories !== null ||
|
|
1764
|
+
review.inlineCommentsPerCategory !== null ||
|
|
1765
|
+
review.minFindingSeverity !== null ||
|
|
1766
|
+
maxFindingsPresent(review.maxFindings) ||
|
|
1767
|
+
review.commentVerbosity !== null ||
|
|
1768
|
+
review.e2eTestDelivery !== null ||
|
|
1769
|
+
review.e2eTestAutoTrigger !== null ||
|
|
1770
|
+
review.pathInstructions.length > 0 ||
|
|
1771
|
+
review.instructions !== null ||
|
|
1772
|
+
review.excludePaths.length > 0 ||
|
|
1773
|
+
review.pathFilters.length > 0 ||
|
|
1774
|
+
review.preMergeChecks.length > 0 ||
|
|
1775
|
+
autoReviewPresent(review.autoReview) ||
|
|
1776
|
+
selfHostAiModelPresent(review.aiModel) ||
|
|
1777
|
+
visualConfigPresent(review.visual) ||
|
|
1778
|
+
review.linkedIssueSatisfaction !== null ||
|
|
1779
|
+
Object.keys(review.fields).length > 0 ||
|
|
1780
|
+
Object.keys(review.enrichmentAnalyzers).length > 0);
|
|
1781
|
+
}
|
|
1782
|
+
/** Overlay a higher-priority `review:` config onto a shared/base layer (#2046). Per-field: override wins when set;
|
|
1783
|
+
* base fills gaps; defaults stay byte-identical. `sharedConfigSource` on the override is preserved when present. */
|
|
1784
|
+
export function overlayReviewConfig(base, override) {
|
|
1785
|
+
const merged = {
|
|
1786
|
+
footerText: pickOverlayNullable(override.footerText, base.footerText),
|
|
1787
|
+
note: pickOverlayNullable(override.note, base.note),
|
|
1788
|
+
fields: pickOverlayPartialRecord(override.fields, base.fields),
|
|
1789
|
+
enrichmentAnalyzers: pickOverlayPartialRecord(override.enrichmentAnalyzers, base.enrichmentAnalyzers),
|
|
1790
|
+
profile: pickOverlayNullable(override.profile, base.profile),
|
|
1791
|
+
tone: pickOverlayNullable(override.tone, base.tone),
|
|
1792
|
+
securityFocus: pickOverlayNullable(override.securityFocus, base.securityFocus),
|
|
1793
|
+
inlineComments: pickOverlayNullable(override.inlineComments, base.inlineComments),
|
|
1794
|
+
fixHandoff: pickOverlayNullable(override.fixHandoff, base.fixHandoff),
|
|
1795
|
+
autoMergeSummary: pickOverlayNullable(override.autoMergeSummary, base.autoMergeSummary),
|
|
1796
|
+
suggestions: pickOverlayNullable(override.suggestions, base.suggestions),
|
|
1797
|
+
changedFilesSummary: pickOverlayNullable(override.changedFilesSummary, base.changedFilesSummary),
|
|
1798
|
+
effortScore: pickOverlayNullable(override.effortScore, base.effortScore),
|
|
1799
|
+
impactMap: pickOverlayNullable(override.impactMap, base.impactMap),
|
|
1800
|
+
cultureProfile: pickOverlayNullable(override.cultureProfile, base.cultureProfile),
|
|
1801
|
+
selftune: pickOverlayNullable(override.selftune, base.selftune),
|
|
1802
|
+
reviewMemory: pickOverlayNullable(override.reviewMemory, base.reviewMemory),
|
|
1803
|
+
findingCategories: pickOverlayNullable(override.findingCategories, base.findingCategories),
|
|
1804
|
+
inlineCommentsPerCategory: pickOverlayNullable(override.inlineCommentsPerCategory, base.inlineCommentsPerCategory),
|
|
1805
|
+
minFindingSeverity: pickOverlayNullable(override.minFindingSeverity, base.minFindingSeverity),
|
|
1806
|
+
maxFindings: overlayMaxFindingsConfig(base.maxFindings, override.maxFindings),
|
|
1807
|
+
commentVerbosity: pickOverlayNullable(override.commentVerbosity, base.commentVerbosity),
|
|
1808
|
+
e2eTestDelivery: pickOverlayNullable(override.e2eTestDelivery, base.e2eTestDelivery),
|
|
1809
|
+
e2eTestAutoTrigger: pickOverlayNullable(override.e2eTestAutoTrigger, base.e2eTestAutoTrigger),
|
|
1810
|
+
pathInstructions: override.pathInstructions.length > 0 ? [...override.pathInstructions] : [...base.pathInstructions],
|
|
1811
|
+
instructions: pickOverlayNullable(override.instructions, base.instructions),
|
|
1812
|
+
excludePaths: pickOverlayStringList(override.excludePaths, base.excludePaths),
|
|
1813
|
+
pathFilters: pickOverlayStringList(override.pathFilters, base.pathFilters),
|
|
1814
|
+
preMergeChecks: override.preMergeChecks.length > 0 ? [...override.preMergeChecks] : [...base.preMergeChecks],
|
|
1815
|
+
autoReview: overlayAutoReviewConfig(base.autoReview, override.autoReview),
|
|
1816
|
+
aiModel: overlaySelfHostAiModelConfig(base.aiModel, override.aiModel),
|
|
1817
|
+
visual: overlayVisualConfig(base.visual, override.visual),
|
|
1818
|
+
linkedIssueSatisfaction: pickOverlayNullable(override.linkedIssueSatisfaction, base.linkedIssueSatisfaction),
|
|
1819
|
+
sharedConfigSource: override.sharedConfigSource ?? base.sharedConfigSource,
|
|
1820
|
+
present: false,
|
|
1821
|
+
};
|
|
1822
|
+
merged.present = computeReviewConfigPresent(merged);
|
|
1823
|
+
return merged;
|
|
1824
|
+
}
|
|
1825
|
+
/** Parse a raw `review:` mapping value. Exported for the private-config shared overlay (#2046). */
|
|
1826
|
+
export function parseReviewConfigMapping(value, warnings) {
|
|
1827
|
+
return parseReviewConfig(value, warnings);
|
|
1828
|
+
}
|
|
1829
|
+
function maxFindingsPresent(config) {
|
|
1830
|
+
return config.blockers !== null || config.nits !== null;
|
|
1831
|
+
}
|
|
1832
|
+
/** Parse `review.max_findings` — optional non-negative caps for blockers/nits display in the unified comment. */
|
|
1833
|
+
function parseMaxFindingsConfig(value, warnings) {
|
|
1834
|
+
if (value === undefined || value === null)
|
|
1835
|
+
return { ...EMPTY_MAX_FINDINGS_CONFIG };
|
|
1836
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1837
|
+
warnings.push(`Manifest "review.max_findings" must be a mapping; ignoring it.`);
|
|
1838
|
+
return { ...EMPTY_MAX_FINDINGS_CONFIG };
|
|
1839
|
+
}
|
|
1840
|
+
const record = value;
|
|
1841
|
+
return {
|
|
1842
|
+
blockers: normalizeOptionalNonNegativeInt(record.blockers, "review.max_findings.blockers", warnings),
|
|
1843
|
+
nits: normalizeOptionalNonNegativeInt(record.nits, "review.max_findings.nits", warnings),
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
function autoReviewPresent(config) {
|
|
1847
|
+
return (config.skipDrafts !== null ||
|
|
1848
|
+
config.cadence !== null ||
|
|
1849
|
+
config.ignoreAuthors.length > 0 ||
|
|
1850
|
+
config.ignoreTitleKeywords.length > 0 ||
|
|
1851
|
+
config.skipLabels.length > 0 ||
|
|
1852
|
+
config.skipDocsOnly !== null ||
|
|
1853
|
+
config.maxAddedLines > 0 ||
|
|
1854
|
+
config.maxFiles > 0 ||
|
|
1855
|
+
config.baseBranches.length > 0 ||
|
|
1856
|
+
config.autoPauseAfterReviewedCommits !== null);
|
|
1857
|
+
}
|
|
1858
|
+
/** Parse `review.auto_review` — deterministic AI review eligibility filters. (#1954 / #2038–#2041) */
|
|
1859
|
+
function parseAutoReviewConfig(value, warnings) {
|
|
1860
|
+
if (value === undefined || value === null)
|
|
1861
|
+
return { ...EMPTY_AUTO_REVIEW_CONFIG };
|
|
1862
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1863
|
+
warnings.push(`Manifest field "review.auto_review" must be a mapping; ignoring it.`);
|
|
1864
|
+
return { ...EMPTY_AUTO_REVIEW_CONFIG };
|
|
1865
|
+
}
|
|
1866
|
+
const record = value;
|
|
1867
|
+
return {
|
|
1868
|
+
skipDrafts: normalizeOptionalBoolean(record.skip_drafts, "review.auto_review.skip_drafts", warnings),
|
|
1869
|
+
cadence: normalizeOptionalEnum(record.cadence, "review.auto_review.cadence", AI_REVIEW_CADENCES, warnings),
|
|
1870
|
+
ignoreAuthors: parseManifestGlobList(record.ignore_authors, "review.auto_review.ignore_authors", warnings),
|
|
1871
|
+
ignoreTitleKeywords: parseAutoReviewTitleKeywords(record.ignore_title_keywords, warnings),
|
|
1872
|
+
skipLabels: parseAutoReviewSkipLabels(record.skip_labels, warnings),
|
|
1873
|
+
skipDocsOnly: normalizeOptionalBoolean(record.skip_docs_only, "review.auto_review.skip_docs_only", warnings),
|
|
1874
|
+
maxAddedLines: normalizeAutoReviewSizeCap(record.max_added_lines, "review.auto_review.max_added_lines", warnings),
|
|
1875
|
+
maxFiles: normalizeAutoReviewSizeCap(record.max_files, "review.auto_review.max_files", warnings),
|
|
1876
|
+
baseBranches: parseManifestGlobList(record.base_branches, "review.auto_review.base_branches", warnings),
|
|
1877
|
+
autoPauseAfterReviewedCommits: normalizeOptionalNonNegativeInt(record.auto_pause_after_reviewed_commits, "review.auto_review.auto_pause_after_reviewed_commits", warnings),
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
function selfHostAiModelPresent(config) {
|
|
1881
|
+
return (config.claudeModel !== null ||
|
|
1882
|
+
config.claudeEffort !== null ||
|
|
1883
|
+
config.codexModel !== null ||
|
|
1884
|
+
config.codexEffort !== null ||
|
|
1885
|
+
config.ollamaModel !== null ||
|
|
1886
|
+
config.openaiModel !== null ||
|
|
1887
|
+
config.openaiCompatibleModel !== null ||
|
|
1888
|
+
config.anthropicModel !== null);
|
|
1889
|
+
}
|
|
1890
|
+
/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded,
|
|
1891
|
+
* public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this
|
|
1892
|
+
* parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value
|
|
1893
|
+
* degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time).
|
|
1894
|
+
* (#selfhost-ai-model-override) */
|
|
1895
|
+
function parseSelfHostAiModelConfig(value, warnings) {
|
|
1896
|
+
if (value === undefined || value === null)
|
|
1897
|
+
return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
|
|
1898
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1899
|
+
warnings.push(`Manifest field "review.ai_model" must be a mapping; ignoring it.`);
|
|
1900
|
+
return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
|
|
1901
|
+
}
|
|
1902
|
+
const record = value;
|
|
1903
|
+
return {
|
|
1904
|
+
claudeModel: parsePublicSafeText(record.claude_model, "review.ai_model.claude_model", warnings),
|
|
1905
|
+
claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings),
|
|
1906
|
+
codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings),
|
|
1907
|
+
codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings),
|
|
1908
|
+
ollamaModel: parsePublicSafeText(record.ollama_model, "review.ai_model.ollama_model", warnings),
|
|
1909
|
+
openaiModel: parsePublicSafeText(record.openai_model, "review.ai_model.openai_model", warnings),
|
|
1910
|
+
openaiCompatibleModel: parsePublicSafeText(record.openai_compatible_model, "review.ai_model.openai_compatible_model", warnings),
|
|
1911
|
+
anthropicModel: parsePublicSafeText(record.anthropic_model, "review.ai_model.anthropic_model", warnings),
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
function visualConfigPresent(config) {
|
|
1915
|
+
return (config.productionUrl !== null ||
|
|
1916
|
+
config.preview.urlTemplate !== null ||
|
|
1917
|
+
config.routes.paths.length > 0 ||
|
|
1918
|
+
config.routes.maxRoutes !== null ||
|
|
1919
|
+
config.themes.length > 0 ||
|
|
1920
|
+
config.gif ||
|
|
1921
|
+
config.enabled !== null ||
|
|
1922
|
+
config.themeStorageKey !== null ||
|
|
1923
|
+
config.actionsFallback);
|
|
1924
|
+
}
|
|
1925
|
+
const VISUAL_THEME_VALUES = ["light", "dark"];
|
|
1926
|
+
/** Parse `review.visual.themes` — which `prefers-color-scheme` variants to capture (#3678). Empty/default ⇒
|
|
1927
|
+
* the capture pipeline falls back to a single light-theme render, byte-identical to today. Unlike
|
|
1928
|
+
* `routes.paths` (an open-ended glob list), this is a closed 2-value enum, so entries are validated against
|
|
1929
|
+
* it directly rather than reusing the generic glob-list parser. */
|
|
1930
|
+
function parseVisualThemes(value, warnings) {
|
|
1931
|
+
if (value === undefined || value === null)
|
|
1932
|
+
return [];
|
|
1933
|
+
if (!Array.isArray(value)) {
|
|
1934
|
+
warnings.push(`Manifest "review.visual.themes" must be a list of "light"/"dark"; ignoring it.`);
|
|
1935
|
+
return [];
|
|
1936
|
+
}
|
|
1937
|
+
const out = [];
|
|
1938
|
+
for (const [index, entry] of value.entries()) {
|
|
1939
|
+
const theme = typeof entry === "string" ? entry.trim().toLowerCase() : undefined;
|
|
1940
|
+
if (!theme || !VISUAL_THEME_VALUES.includes(theme)) {
|
|
1941
|
+
warnings.push(`Manifest "review.visual.themes[${index}]" must be "light" or "dark"; ignoring it.`);
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
if (!out.includes(theme))
|
|
1945
|
+
out.push(theme);
|
|
1946
|
+
}
|
|
1947
|
+
return out;
|
|
1948
|
+
}
|
|
1949
|
+
// `{number}`/`{head_sha}`/`{head_sha_short}` are GitHub-controlled facts about the PR (never attacker-supplied
|
|
1950
|
+
// free text), so substitution itself carries no injection risk. The dummy values here exist only to make the
|
|
1951
|
+
// TEMPLATE STRING (which a maintainer authored, and could still typo) validate as a well-formed HTTPS URL
|
|
1952
|
+
// before it's ever used — see parseVisualUrlTemplate below.
|
|
1953
|
+
const VISUAL_URL_TEMPLATE_DUMMY_VARS = {
|
|
1954
|
+
"{number}": "1",
|
|
1955
|
+
"{head_sha_short}": "0000000",
|
|
1956
|
+
"{head_sha}": "0000000000000000000000000000000000000000",
|
|
1957
|
+
};
|
|
1958
|
+
/** Parse `review.visual.production_url` — validated at CONFIG-READ time against the exact same SSRF guard
|
|
1959
|
+
* (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to. Unlike
|
|
1960
|
+
* `preview.url_template`, this is a plain static origin with no `{number}`/`{head_sha}` placeholders to
|
|
1961
|
+
* substitute — the "before" shot is always the SAME production page, just at a different path per route. */
|
|
1962
|
+
function parseVisualProductionUrl(value, warnings) {
|
|
1963
|
+
const url = parsePublicSafeText(value, "review.visual.production_url", warnings);
|
|
1964
|
+
if (url === null)
|
|
1965
|
+
return null;
|
|
1966
|
+
if (!isSafeHttpUrl(url)) {
|
|
1967
|
+
warnings.push(`Manifest "review.visual.production_url" must be a valid HTTPS URL targeting a public host; ignoring it.`);
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
return url;
|
|
1971
|
+
}
|
|
1972
|
+
/** Parse `review.visual.preview.url_template` — validated at CONFIG-READ time against the exact same SSRF
|
|
1973
|
+
* guard (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to,
|
|
1974
|
+
* regardless of source (`src/review/visual/shot.ts`). This is deliberately redundant with that runtime
|
|
1975
|
+
* check, not a replacement for it — it exists so a maintainer sees a warning immediately for a malformed
|
|
1976
|
+
* template (e.g. a typo'd scheme, or an accidental internal host) instead of only discovering it later as
|
|
1977
|
+
* a silently-blank "after" cell. Placeholders are substituted with dummy values before validation since the
|
|
1978
|
+
* raw template (e.g. `https://pr-{number}.example.com`) is not itself a parseable URL. */
|
|
1979
|
+
function parseVisualUrlTemplate(value, warnings) {
|
|
1980
|
+
const template = parsePublicSafeText(value, "review.visual.preview.url_template", warnings);
|
|
1981
|
+
if (template === null)
|
|
1982
|
+
return null;
|
|
1983
|
+
let probe = template;
|
|
1984
|
+
for (const [placeholder, dummy] of Object.entries(VISUAL_URL_TEMPLATE_DUMMY_VARS))
|
|
1985
|
+
probe = probe.split(placeholder).join(dummy);
|
|
1986
|
+
if (!isSafeHttpUrl(probe)) {
|
|
1987
|
+
warnings.push(`Manifest "review.visual.preview.url_template" must be a valid HTTPS URL (with {number}/{head_sha}/{head_sha_short} placeholders substituted) targeting a public host; ignoring it.`);
|
|
1988
|
+
return null;
|
|
1989
|
+
}
|
|
1990
|
+
return template;
|
|
1991
|
+
}
|
|
1992
|
+
/** Parse `review.visual` — per-repo before/after screenshot-capture config (#3609 preview / #3610 routes /
|
|
1993
|
+
* #3678 themes). */
|
|
1994
|
+
function parseVisualConfig(value, warnings) {
|
|
1995
|
+
if (value === undefined || value === null)
|
|
1996
|
+
return { ...EMPTY_VISUAL_CONFIG };
|
|
1997
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1998
|
+
warnings.push(`Manifest field "review.visual" must be a mapping; ignoring it.`);
|
|
1999
|
+
return { ...EMPTY_VISUAL_CONFIG };
|
|
2000
|
+
}
|
|
2001
|
+
const record = value;
|
|
2002
|
+
const productionUrl = parseVisualProductionUrl(record.production_url, warnings);
|
|
2003
|
+
const previewRecord = record.preview !== null && typeof record.preview === "object" && !Array.isArray(record.preview) ? record.preview : undefined;
|
|
2004
|
+
if (record.preview !== undefined && record.preview !== null && previewRecord === undefined) {
|
|
2005
|
+
warnings.push(`Manifest "review.visual.preview" must be a mapping; ignoring it.`);
|
|
2006
|
+
}
|
|
2007
|
+
const urlTemplate = previewRecord ? parseVisualUrlTemplate(previewRecord.url_template, warnings) : null;
|
|
2008
|
+
const routesRecord = record.routes !== null && typeof record.routes === "object" && !Array.isArray(record.routes) ? record.routes : undefined;
|
|
2009
|
+
if (record.routes !== undefined && record.routes !== null && routesRecord === undefined) {
|
|
2010
|
+
warnings.push(`Manifest "review.visual.routes" must be a mapping; ignoring it.`);
|
|
2011
|
+
}
|
|
2012
|
+
const paths = routesRecord ? parseManifestGlobList(routesRecord.paths, "review.visual.routes.paths", warnings) : [];
|
|
2013
|
+
const maxRoutes = routesRecord ? normalizeOptionalVisualMaxRoutes(routesRecord.max_routes, warnings) : null;
|
|
2014
|
+
const themes = parseVisualThemes(record.themes, warnings);
|
|
2015
|
+
const gif = normalizeOptionalBoolean(record.gif, "review.visual.gif", warnings) === true;
|
|
2016
|
+
const enabled = normalizeOptionalBoolean(record.enabled, "review.visual.enabled", warnings);
|
|
2017
|
+
const themeStorageKey = parsePublicSafeText(record.theme_storage_key, "review.visual.theme_storage_key", warnings);
|
|
2018
|
+
const actionsFallback = normalizeOptionalBoolean(record.actions_fallback, "review.visual.actions_fallback", warnings) === true;
|
|
2019
|
+
return { productionUrl, preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey, actionsFallback };
|
|
2020
|
+
}
|
|
2021
|
+
function parseAutoReviewTitleKeywords(value, warnings) {
|
|
2022
|
+
if (value === undefined || value === null)
|
|
2023
|
+
return [];
|
|
2024
|
+
if (!Array.isArray(value)) {
|
|
2025
|
+
warnings.push(`Manifest "review.auto_review.ignore_title_keywords" must be a list of strings; ignoring it.`);
|
|
2026
|
+
return [];
|
|
2027
|
+
}
|
|
2028
|
+
const out = [];
|
|
2029
|
+
for (const [index, entry] of value.entries()) {
|
|
2030
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2031
|
+
warnings.push(`Manifest "review.auto_review.ignore_title_keywords" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2032
|
+
break;
|
|
2033
|
+
}
|
|
2034
|
+
const raw = typeof entry === "string" ? entry.trim() : "";
|
|
2035
|
+
if (!raw) {
|
|
2036
|
+
warnings.push(`Manifest "review.auto_review.ignore_title_keywords[${index}]" must be a non-empty string; ignoring it.`);
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
const safe = parsePublicSafeText(raw, `review.auto_review.ignore_title_keywords[${index}]`, warnings);
|
|
2040
|
+
if (safe !== null)
|
|
2041
|
+
out.push(safe);
|
|
2042
|
+
}
|
|
2043
|
+
return out;
|
|
2044
|
+
}
|
|
2045
|
+
function parseAutoReviewSkipLabels(value, warnings) {
|
|
2046
|
+
if (value === undefined || value === null)
|
|
2047
|
+
return [];
|
|
2048
|
+
if (!Array.isArray(value)) {
|
|
2049
|
+
warnings.push(`Manifest "review.auto_review.skip_labels" must be a list of strings; ignoring it.`);
|
|
2050
|
+
return [];
|
|
2051
|
+
}
|
|
2052
|
+
const seen = new Set();
|
|
2053
|
+
const out = [];
|
|
2054
|
+
for (const [index, entry] of value.entries()) {
|
|
2055
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2056
|
+
warnings.push(`Manifest "review.auto_review.skip_labels" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2057
|
+
break;
|
|
2058
|
+
}
|
|
2059
|
+
const raw = typeof entry === "string" ? entry.trim() : "";
|
|
2060
|
+
if (!raw) {
|
|
2061
|
+
warnings.push(`Manifest "review.auto_review.skip_labels[${index}]" must be a non-empty string; ignoring it.`);
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
const safe = parsePublicSafeText(raw, `review.auto_review.skip_labels[${index}]`, warnings);
|
|
2065
|
+
if (safe === null)
|
|
2066
|
+
continue;
|
|
2067
|
+
const key = safe.toLowerCase();
|
|
2068
|
+
if (seen.has(key))
|
|
2069
|
+
continue;
|
|
2070
|
+
seen.add(key);
|
|
2071
|
+
out.push(key);
|
|
2072
|
+
}
|
|
2073
|
+
return out;
|
|
2074
|
+
}
|
|
2075
|
+
/** Parse `review.pre_merge_checks` — an array of DETERMINISTIC pre-merge assertions. Each entry needs a non-empty
|
|
2076
|
+
* public-safe `name` and at least ONE assertion (`title_contains` / `description_contains` / `require_label`,
|
|
2077
|
+
* each public-safe); `when_paths` (optional) gates the check to PRs touching a matching glob; `enforce` (default
|
|
2078
|
+
* false) makes a failure a hard blocker. Invalid entries are dropped with a warning; capped at
|
|
2079
|
+
* MAX_PATH_INSTRUCTIONS so a hostile manifest can't bloat the gate. (#review-pre-merge-checks) */
|
|
2080
|
+
function parseReviewPreMergeChecks(value, warnings) {
|
|
2081
|
+
if (value === undefined || value === null)
|
|
2082
|
+
return [];
|
|
2083
|
+
if (!Array.isArray(value)) {
|
|
2084
|
+
warnings.push(`Manifest "review.pre_merge_checks" must be a list of checks; ignoring it.`);
|
|
2085
|
+
return [];
|
|
2086
|
+
}
|
|
2087
|
+
const out = [];
|
|
2088
|
+
for (const [index, entry] of value.entries()) {
|
|
2089
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2090
|
+
warnings.push(`Manifest "review.pre_merge_checks" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2091
|
+
break;
|
|
2092
|
+
}
|
|
2093
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2094
|
+
warnings.push(`Manifest "review.pre_merge_checks[${index}]" must be a mapping; ignoring it.`);
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
const e = entry;
|
|
2098
|
+
if (e.name === undefined || e.name === null) {
|
|
2099
|
+
warnings.push(`Manifest "review.pre_merge_checks[${index}].name" is required; ignoring the entry.`);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
const name = parsePublicSafeText(e.name, `review.pre_merge_checks[${index}].name`, warnings);
|
|
2103
|
+
if (name === null)
|
|
2104
|
+
continue; // non-string / empty / not-public-safe → already warned
|
|
2105
|
+
const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.pre_merge_checks[${index}].title_contains`, warnings);
|
|
2106
|
+
const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.pre_merge_checks[${index}].description_contains`, warnings);
|
|
2107
|
+
const requireLabel = e.require_label === undefined || e.require_label === null ? null : parsePublicSafeText(e.require_label, `review.pre_merge_checks[${index}].require_label`, warnings);
|
|
2108
|
+
if (titleContains === null && descriptionContains === null && requireLabel === null) {
|
|
2109
|
+
warnings.push(`Manifest "review.pre_merge_checks[${index}]" needs at least one of title_contains / description_contains / require_label; ignoring it.`);
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
const whenPaths = parseManifestGlobList(e.when_paths, `review.pre_merge_checks[${index}].when_paths`, warnings);
|
|
2113
|
+
const enforce = normalizeOptionalBoolean(e.enforce, `review.pre_merge_checks[${index}].enforce`, warnings) === true;
|
|
2114
|
+
out.push({ name, whenPaths, titleContains, descriptionContains, requireLabel, enforce });
|
|
2115
|
+
}
|
|
2116
|
+
return out;
|
|
2117
|
+
}
|
|
2118
|
+
/** Parse a manifest glob list (e.g. `review.exclude_paths`, a check's `when_paths`) — an array of non-empty
|
|
2119
|
+
* string globs; blanks/non-strings are dropped with a warning. Capped at MAX_PATH_INSTRUCTIONS so a hostile
|
|
2120
|
+
* manifest can't bloat the matcher. `fieldLabel` makes the warnings name the right field. */
|
|
2121
|
+
function parseManifestGlobList(value, fieldLabel, warnings) {
|
|
2122
|
+
if (value === undefined || value === null)
|
|
2123
|
+
return [];
|
|
2124
|
+
if (!Array.isArray(value)) {
|
|
2125
|
+
warnings.push(`Manifest "${fieldLabel}" must be a list of path globs; ignoring it.`);
|
|
2126
|
+
return [];
|
|
2127
|
+
}
|
|
2128
|
+
const out = [];
|
|
2129
|
+
const seen = new Set();
|
|
2130
|
+
for (const [index, entry] of value.entries()) {
|
|
2131
|
+
const glob = typeof entry === "string" ? entry.trim() : "";
|
|
2132
|
+
if (!glob) {
|
|
2133
|
+
warnings.push(`Manifest "${fieldLabel}[${index}]" must be a non-empty string; ignoring it.`);
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
if (glob.length > MAX_ITEM_LENGTH) {
|
|
2137
|
+
warnings.push(`Manifest "${fieldLabel}[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`);
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
const key = glob.toLowerCase();
|
|
2141
|
+
if (seen.has(key))
|
|
2142
|
+
continue;
|
|
2143
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2144
|
+
warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2145
|
+
break;
|
|
2146
|
+
}
|
|
2147
|
+
seen.add(key);
|
|
2148
|
+
out.push(glob);
|
|
2149
|
+
}
|
|
2150
|
+
return out;
|
|
2151
|
+
}
|
|
2152
|
+
/** Parse `review.exclude_paths` — globs whose matching files are excluded from the AI review. (#review-exclude-paths) */
|
|
2153
|
+
function parseReviewExcludePaths(value, warnings) {
|
|
2154
|
+
return parseManifestGlobList(value, "review.exclude_paths", warnings);
|
|
2155
|
+
}
|
|
2156
|
+
/** Parse `review.path_filters` — include globs plus optional leading-`!` negation entries. (#2043) */
|
|
2157
|
+
function parseReviewPathFilters(value, warnings) {
|
|
2158
|
+
if (value === undefined || value === null)
|
|
2159
|
+
return [];
|
|
2160
|
+
if (!Array.isArray(value)) {
|
|
2161
|
+
warnings.push(`Manifest "review.path_filters" must be a list of path globs; ignoring it.`);
|
|
2162
|
+
return [];
|
|
2163
|
+
}
|
|
2164
|
+
const out = [];
|
|
2165
|
+
for (const [index, entry] of value.entries()) {
|
|
2166
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2167
|
+
warnings.push(`Manifest "review.path_filters" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2168
|
+
break;
|
|
2169
|
+
}
|
|
2170
|
+
const raw = typeof entry === "string" ? entry.trim() : "";
|
|
2171
|
+
if (!raw) {
|
|
2172
|
+
warnings.push(`Manifest "review.path_filters[${index}]" must be a non-empty string; ignoring it.`);
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const negated = raw.startsWith("!");
|
|
2176
|
+
const glob = negated ? raw.slice(1).trim() : raw;
|
|
2177
|
+
if (!glob) {
|
|
2178
|
+
warnings.push(`Manifest "review.path_filters[${index}]" must include a glob after a leading '!'; ignoring it.`);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
if (glob.length > MAX_ITEM_LENGTH) {
|
|
2182
|
+
warnings.push(`Manifest "review.path_filters[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`);
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
out.push(negated ? `!${glob}` : glob);
|
|
2186
|
+
}
|
|
2187
|
+
return out;
|
|
2188
|
+
}
|
|
2189
|
+
/** Parse `review.path_instructions` — an array of `{ path, instructions }` entries. Each must have a non-empty
|
|
2190
|
+
* string `path` (a manifest glob) and PUBLIC-SAFE string `instructions`; invalid/unsafe entries are dropped with
|
|
2191
|
+
* a warning. Capped at MAX_PATH_INSTRUCTIONS so a huge manifest can't bloat the reviewer prompt. */
|
|
2192
|
+
function parseReviewPathInstructions(value, warnings) {
|
|
2193
|
+
if (value === undefined || value === null)
|
|
2194
|
+
return [];
|
|
2195
|
+
if (!Array.isArray(value)) {
|
|
2196
|
+
warnings.push(`Manifest "review.path_instructions" must be a list of { path, instructions }; ignoring it.`);
|
|
2197
|
+
return [];
|
|
2198
|
+
}
|
|
2199
|
+
const out = [];
|
|
2200
|
+
for (const [index, entry] of value.entries()) {
|
|
2201
|
+
if (out.length >= MAX_PATH_INSTRUCTIONS) {
|
|
2202
|
+
warnings.push(`Manifest "review.path_instructions" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
|
|
2203
|
+
break;
|
|
2204
|
+
}
|
|
2205
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2206
|
+
warnings.push(`Manifest "review.path_instructions[${index}]" must be a mapping with path + instructions; ignoring it.`);
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
const e = entry;
|
|
2210
|
+
const path = typeof e.path === "string" ? e.path.trim() : "";
|
|
2211
|
+
if (!path) {
|
|
2212
|
+
warnings.push(`Manifest "review.path_instructions[${index}].path" must be a non-empty string; ignoring the entry.`);
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
if (path.length > MAX_ITEM_LENGTH) {
|
|
2216
|
+
warnings.push(`Manifest "review.path_instructions[${index}].path" exceeds ${MAX_ITEM_LENGTH} chars; ignoring the entry.`);
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (e.instructions === undefined || e.instructions === null) {
|
|
2220
|
+
warnings.push(`Manifest "review.path_instructions[${index}].instructions" is required; ignoring the entry.`);
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
const instructions = parsePublicSafeText(e.instructions, `review.path_instructions[${index}].instructions`, warnings);
|
|
2224
|
+
if (instructions === null)
|
|
2225
|
+
continue; // non-string / empty / not-public-safe → already warned
|
|
2226
|
+
out.push({ path, instructions });
|
|
2227
|
+
}
|
|
2228
|
+
return out;
|
|
2229
|
+
}
|
|
2230
|
+
/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to
|
|
2231
|
+
* null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */
|
|
2232
|
+
function parseReviewProfile(value, warnings) {
|
|
2233
|
+
if (value === undefined || value === null)
|
|
2234
|
+
return null;
|
|
2235
|
+
if (typeof value !== "string") {
|
|
2236
|
+
warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`);
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
const normalized = value.trim().toLowerCase();
|
|
2240
|
+
if (normalized === "balanced")
|
|
2241
|
+
return null; // default → no prompt change
|
|
2242
|
+
if (normalized === "chill" || normalized === "assertive")
|
|
2243
|
+
return normalized;
|
|
2244
|
+
warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`);
|
|
2245
|
+
return null;
|
|
2246
|
+
}
|
|
2247
|
+
/** Serialize the review config for the cache round-trip; returns null when nothing is set. */
|
|
2248
|
+
export function reviewConfigToJson(review) {
|
|
2249
|
+
if (!review.present)
|
|
2250
|
+
return null;
|
|
2251
|
+
const out = {};
|
|
2252
|
+
if (review.footerText !== null)
|
|
2253
|
+
out.footer = { text: review.footerText };
|
|
2254
|
+
if (review.note !== null)
|
|
2255
|
+
out.note = review.note;
|
|
2256
|
+
if (review.profile !== null)
|
|
2257
|
+
out.profile = review.profile;
|
|
2258
|
+
if (review.tone !== null)
|
|
2259
|
+
out.tone = review.tone;
|
|
2260
|
+
if (review.securityFocus !== null)
|
|
2261
|
+
out.security_focus = review.securityFocus;
|
|
2262
|
+
if (review.inlineComments !== null)
|
|
2263
|
+
out.inline_comments = review.inlineComments;
|
|
2264
|
+
if (review.fixHandoff !== null)
|
|
2265
|
+
out.fixHandoff = review.fixHandoff;
|
|
2266
|
+
if (review.autoMergeSummary !== null)
|
|
2267
|
+
out.auto_merge_summary = review.autoMergeSummary;
|
|
2268
|
+
if (review.suggestions !== null)
|
|
2269
|
+
out.suggestions = review.suggestions;
|
|
2270
|
+
if (review.changedFilesSummary !== null)
|
|
2271
|
+
out.changed_files_summary = review.changedFilesSummary;
|
|
2272
|
+
if (review.effortScore !== null)
|
|
2273
|
+
out.effort_score = review.effortScore;
|
|
2274
|
+
if (review.impactMap !== null)
|
|
2275
|
+
out.impact_map = review.impactMap;
|
|
2276
|
+
if (review.cultureProfile !== null)
|
|
2277
|
+
out.culture_profile = review.cultureProfile;
|
|
2278
|
+
if (review.selftune !== null)
|
|
2279
|
+
out.selftune = review.selftune;
|
|
2280
|
+
if (review.reviewMemory !== null)
|
|
2281
|
+
out.memory = review.reviewMemory;
|
|
2282
|
+
if (review.findingCategories !== null)
|
|
2283
|
+
out.finding_categories = review.findingCategories;
|
|
2284
|
+
if (review.inlineCommentsPerCategory !== null)
|
|
2285
|
+
out.inline_comments_per_category = review.inlineCommentsPerCategory;
|
|
2286
|
+
if (review.minFindingSeverity !== null)
|
|
2287
|
+
out.min_finding_severity = review.minFindingSeverity;
|
|
2288
|
+
if (maxFindingsPresent(review.maxFindings)) {
|
|
2289
|
+
const maxFindings = {};
|
|
2290
|
+
if (review.maxFindings.blockers !== null)
|
|
2291
|
+
maxFindings.blockers = review.maxFindings.blockers;
|
|
2292
|
+
if (review.maxFindings.nits !== null)
|
|
2293
|
+
maxFindings.nits = review.maxFindings.nits;
|
|
2294
|
+
out.max_findings = maxFindings;
|
|
2295
|
+
}
|
|
2296
|
+
if (review.commentVerbosity !== null)
|
|
2297
|
+
out.comment_verbosity = review.commentVerbosity;
|
|
2298
|
+
if (review.e2eTestDelivery !== null)
|
|
2299
|
+
out.e2e_test_delivery = review.e2eTestDelivery;
|
|
2300
|
+
if (review.e2eTestAutoTrigger !== null)
|
|
2301
|
+
out.e2e_test_auto_trigger = review.e2eTestAutoTrigger;
|
|
2302
|
+
if (review.instructions !== null)
|
|
2303
|
+
out.instructions = review.instructions;
|
|
2304
|
+
if (review.pathInstructions.length > 0)
|
|
2305
|
+
out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions }));
|
|
2306
|
+
if (review.excludePaths.length > 0)
|
|
2307
|
+
out.exclude_paths = [...review.excludePaths];
|
|
2308
|
+
if (review.pathFilters.length > 0)
|
|
2309
|
+
out.path_filters = [...review.pathFilters];
|
|
2310
|
+
if (autoReviewPresent(review.autoReview)) {
|
|
2311
|
+
const autoReview = {};
|
|
2312
|
+
if (review.autoReview.skipDrafts !== null)
|
|
2313
|
+
autoReview.skip_drafts = review.autoReview.skipDrafts;
|
|
2314
|
+
if (review.autoReview.cadence !== null)
|
|
2315
|
+
autoReview.cadence = review.autoReview.cadence;
|
|
2316
|
+
if (review.autoReview.ignoreAuthors.length > 0)
|
|
2317
|
+
autoReview.ignore_authors = [...review.autoReview.ignoreAuthors];
|
|
2318
|
+
if (review.autoReview.ignoreTitleKeywords.length > 0)
|
|
2319
|
+
autoReview.ignore_title_keywords = [...review.autoReview.ignoreTitleKeywords];
|
|
2320
|
+
if (review.autoReview.skipLabels.length > 0)
|
|
2321
|
+
autoReview.skip_labels = [...review.autoReview.skipLabels];
|
|
2322
|
+
if (review.autoReview.skipDocsOnly !== null)
|
|
2323
|
+
autoReview.skip_docs_only = review.autoReview.skipDocsOnly;
|
|
2324
|
+
if (review.autoReview.maxAddedLines > 0)
|
|
2325
|
+
autoReview.max_added_lines = review.autoReview.maxAddedLines;
|
|
2326
|
+
if (review.autoReview.maxFiles > 0)
|
|
2327
|
+
autoReview.max_files = review.autoReview.maxFiles;
|
|
2328
|
+
if (review.autoReview.baseBranches.length > 0)
|
|
2329
|
+
autoReview.base_branches = [...review.autoReview.baseBranches];
|
|
2330
|
+
if (review.autoReview.autoPauseAfterReviewedCommits !== null) {
|
|
2331
|
+
autoReview.auto_pause_after_reviewed_commits = review.autoReview.autoPauseAfterReviewedCommits;
|
|
2332
|
+
}
|
|
2333
|
+
out.auto_review = autoReview;
|
|
2334
|
+
}
|
|
2335
|
+
if (review.preMergeChecks.length > 0) {
|
|
2336
|
+
out.pre_merge_checks = review.preMergeChecks.map((check) => {
|
|
2337
|
+
const entry = { name: check.name };
|
|
2338
|
+
if (check.whenPaths.length > 0)
|
|
2339
|
+
entry.when_paths = [...check.whenPaths];
|
|
2340
|
+
if (check.titleContains !== null)
|
|
2341
|
+
entry.title_contains = check.titleContains;
|
|
2342
|
+
if (check.descriptionContains !== null)
|
|
2343
|
+
entry.description_contains = check.descriptionContains;
|
|
2344
|
+
if (check.requireLabel !== null)
|
|
2345
|
+
entry.require_label = check.requireLabel;
|
|
2346
|
+
if (check.enforce)
|
|
2347
|
+
entry.enforce = true;
|
|
2348
|
+
return entry;
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
if (Object.keys(review.fields).length > 0)
|
|
2352
|
+
out.fields = { ...review.fields };
|
|
2353
|
+
if (Object.keys(review.enrichmentAnalyzers).length > 0)
|
|
2354
|
+
out.enrichment = { ...review.enrichmentAnalyzers };
|
|
2355
|
+
if (selfHostAiModelPresent(review.aiModel)) {
|
|
2356
|
+
const aiModel = {};
|
|
2357
|
+
if (review.aiModel.claudeModel !== null)
|
|
2358
|
+
aiModel.claude_model = review.aiModel.claudeModel;
|
|
2359
|
+
if (review.aiModel.claudeEffort !== null)
|
|
2360
|
+
aiModel.claude_effort = review.aiModel.claudeEffort;
|
|
2361
|
+
if (review.aiModel.codexModel !== null)
|
|
2362
|
+
aiModel.codex_model = review.aiModel.codexModel;
|
|
2363
|
+
if (review.aiModel.codexEffort !== null)
|
|
2364
|
+
aiModel.codex_effort = review.aiModel.codexEffort;
|
|
2365
|
+
if (review.aiModel.ollamaModel !== null)
|
|
2366
|
+
aiModel.ollama_model = review.aiModel.ollamaModel;
|
|
2367
|
+
if (review.aiModel.openaiModel !== null)
|
|
2368
|
+
aiModel.openai_model = review.aiModel.openaiModel;
|
|
2369
|
+
if (review.aiModel.openaiCompatibleModel !== null)
|
|
2370
|
+
aiModel.openai_compatible_model = review.aiModel.openaiCompatibleModel;
|
|
2371
|
+
if (review.aiModel.anthropicModel !== null)
|
|
2372
|
+
aiModel.anthropic_model = review.aiModel.anthropicModel;
|
|
2373
|
+
out.ai_model = aiModel;
|
|
2374
|
+
}
|
|
2375
|
+
if (visualConfigPresent(review.visual)) {
|
|
2376
|
+
const visual = {};
|
|
2377
|
+
if (review.visual.productionUrl !== null)
|
|
2378
|
+
visual.production_url = review.visual.productionUrl;
|
|
2379
|
+
if (review.visual.preview.urlTemplate !== null)
|
|
2380
|
+
visual.preview = { url_template: review.visual.preview.urlTemplate };
|
|
2381
|
+
if (review.visual.routes.paths.length > 0 || review.visual.routes.maxRoutes !== null) {
|
|
2382
|
+
const routes = {};
|
|
2383
|
+
if (review.visual.routes.paths.length > 0)
|
|
2384
|
+
routes.paths = [...review.visual.routes.paths];
|
|
2385
|
+
if (review.visual.routes.maxRoutes !== null)
|
|
2386
|
+
routes.max_routes = review.visual.routes.maxRoutes;
|
|
2387
|
+
visual.routes = routes;
|
|
2388
|
+
}
|
|
2389
|
+
if (review.visual.themes.length > 0)
|
|
2390
|
+
visual.themes = [...review.visual.themes];
|
|
2391
|
+
if (review.visual.gif)
|
|
2392
|
+
visual.gif = true;
|
|
2393
|
+
if (review.visual.enabled !== null)
|
|
2394
|
+
visual.enabled = review.visual.enabled;
|
|
2395
|
+
if (review.visual.themeStorageKey !== null)
|
|
2396
|
+
visual.theme_storage_key = review.visual.themeStorageKey;
|
|
2397
|
+
if (review.visual.actionsFallback)
|
|
2398
|
+
visual.actions_fallback = true;
|
|
2399
|
+
out.visual = visual;
|
|
2400
|
+
}
|
|
2401
|
+
if (review.linkedIssueSatisfaction !== null)
|
|
2402
|
+
out.linkedIssueSatisfaction = review.linkedIssueSatisfaction;
|
|
2403
|
+
return out;
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Resolve the `review.path_instructions` that APPLY to a PR — those whose glob matches at least one changed path
|
|
2407
|
+
* — into a single prompt section for the AI reviewer, or "" when none match (so the prompt stays byte-identical).
|
|
2408
|
+
* Pure; uses the same manifest path-glob semantics (`matchesManifestPath`) as the rest of the manifest. Capped to
|
|
2409
|
+
* keep the prompt bounded. (#review-path-instructions)
|
|
2410
|
+
*/
|
|
2411
|
+
export function parseFocusManifest(raw, source) {
|
|
2412
|
+
if (raw === undefined || raw === null)
|
|
2413
|
+
return emptyManifest(source ?? "none");
|
|
2414
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
2415
|
+
return emptyManifest(source ?? "api_record", ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]);
|
|
2416
|
+
}
|
|
2417
|
+
const record = raw;
|
|
2418
|
+
const warnings = [];
|
|
2419
|
+
const resolvedSource = normalizeSource(source, record.source, warnings);
|
|
2420
|
+
const manifest = {
|
|
2421
|
+
present: true,
|
|
2422
|
+
source: resolvedSource,
|
|
2423
|
+
wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings),
|
|
2424
|
+
preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings),
|
|
2425
|
+
linkedIssuePolicy: normalizeEnum(record.linkedIssuePolicy, "linkedIssuePolicy", ["required", "preferred", "optional"], "optional", warnings),
|
|
2426
|
+
testExpectations: normalizeStringList(record.testExpectations, "testExpectations", warnings),
|
|
2427
|
+
issueDiscoveryPolicy: normalizeEnum(record.issueDiscoveryPolicy, "issueDiscoveryPolicy", ["encouraged", "neutral", "discouraged"], "neutral", warnings),
|
|
2428
|
+
maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings),
|
|
2429
|
+
publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe),
|
|
2430
|
+
gate: parseGateConfig(record.gate, warnings),
|
|
2431
|
+
settings: parseSettingsOverride(record.settings, warnings, resolvedSource),
|
|
2432
|
+
review: parseReviewConfig(record.review, warnings),
|
|
2433
|
+
features: parseFeaturesConfig(record.features, warnings),
|
|
2434
|
+
experimental: parseExperimentalConfig(record.experimental, warnings),
|
|
2435
|
+
contentLane: parseContentLaneConfig(record.contentLane, warnings),
|
|
2436
|
+
repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings),
|
|
2437
|
+
reviewRecap: parseReviewRecapConfig(record.reviewRecap, warnings),
|
|
2438
|
+
maintainerRecap: parseMaintainerRecapConfig(record.maintainerRecap, warnings),
|
|
2439
|
+
warnings,
|
|
2440
|
+
};
|
|
2441
|
+
if (manifest.wantedPaths.length === 0 &&
|
|
2442
|
+
manifest.preferredLabels.length === 0 &&
|
|
2443
|
+
manifest.testExpectations.length === 0 &&
|
|
2444
|
+
manifest.maintainerNotes.length === 0 &&
|
|
2445
|
+
manifest.publicNotes.length === 0 &&
|
|
2446
|
+
manifest.linkedIssuePolicy === "optional" &&
|
|
2447
|
+
manifest.issueDiscoveryPolicy === "neutral" &&
|
|
2448
|
+
!manifest.gate.present &&
|
|
2449
|
+
Object.keys(manifest.settings).length === 0 &&
|
|
2450
|
+
!manifest.review.present &&
|
|
2451
|
+
!manifest.features.present &&
|
|
2452
|
+
!manifest.experimental.present &&
|
|
2453
|
+
!manifest.contentLane.present &&
|
|
2454
|
+
!manifest.repoDocGeneration.present &&
|
|
2455
|
+
!manifest.reviewRecap.present &&
|
|
2456
|
+
!manifest.maintainerRecap.present) {
|
|
2457
|
+
warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals.");
|
|
2458
|
+
manifest.present = false;
|
|
2459
|
+
}
|
|
2460
|
+
return manifest;
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Parse raw manifest file/record content (JSON or YAML). Malformed content degrades to an empty
|
|
2464
|
+
* manifest with a warning rather than throwing, so a broken `.gittensory` config never breaks analysis.
|
|
2465
|
+
*/
|
|
2466
|
+
export function parseFocusManifestContent(content, source = "repo_file") {
|
|
2467
|
+
if (content === undefined || content === null || content.trim() === "")
|
|
2468
|
+
return emptyManifest(source);
|
|
2469
|
+
if (content.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(content).byteLength > MAX_FOCUS_MANIFEST_BYTES) {
|
|
2470
|
+
return emptyManifest(source, [`Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`]);
|
|
2471
|
+
}
|
|
2472
|
+
const trimmed = content.trim();
|
|
2473
|
+
const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
2474
|
+
let parsed;
|
|
2475
|
+
try {
|
|
2476
|
+
parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed);
|
|
2477
|
+
}
|
|
2478
|
+
catch {
|
|
2479
|
+
return emptyManifest(source, [
|
|
2480
|
+
looksLikeJson
|
|
2481
|
+
? "Manifest content was not valid JSON; ignoring it and falling back to deterministic signals."
|
|
2482
|
+
: "Manifest content was not valid YAML; ignoring it and falling back to deterministic signals.",
|
|
2483
|
+
]);
|
|
2484
|
+
}
|
|
2485
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2486
|
+
return emptyManifest(source, ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]);
|
|
2487
|
+
}
|
|
2488
|
+
return parseFocusManifest(parsed, source);
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Format a manifest's parse `warnings[]` into one grouped, deduped, order-preserving notice for the review
|
|
2492
|
+
* surface — an acceptance criterion of #1670: an invalid/malformed `.gittensory.yml` value should fail
|
|
2493
|
+
* clearly instead of silently falling back to a default. Empty/no warnings ⇒ `null` (byte-identical, no
|
|
2494
|
+
* notice). Pure; reuses the warnings every parser already accumulates rather than a parallel schema. (#2056)
|
|
2495
|
+
*/
|
|
2496
|
+
export function formatManifestValidationNotice(warnings) {
|
|
2497
|
+
const seen = new Set();
|
|
2498
|
+
const deduped = [];
|
|
2499
|
+
for (const warning of warnings) {
|
|
2500
|
+
const trimmed = warning.trim();
|
|
2501
|
+
if (!trimmed || seen.has(trimmed))
|
|
2502
|
+
continue;
|
|
2503
|
+
seen.add(trimmed);
|
|
2504
|
+
deduped.push(trimmed);
|
|
2505
|
+
}
|
|
2506
|
+
if (deduped.length === 0)
|
|
2507
|
+
return null;
|
|
2508
|
+
return deduped.map((warning) => `- ${warning}`).join("\n");
|
|
2509
|
+
}
|
|
2510
|
+
function normalizePathForMatch(path) {
|
|
2511
|
+
return String(path).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase();
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* LINEAR-TIME wildcard matcher for a `*`-glob pattern over an already-normalized path. `*` (and a collapsed
|
|
2515
|
+
* run of `*`) matches any run of characters INCLUDING `/` (gittensory globs cross slashes). Implemented as a
|
|
2516
|
+
* prefix + suffix + ordered-substring (indexOf) scan rather than a `.*`-per-star regex: the old regex
|
|
2517
|
+
* (`^.*a.*a...$`) backtracks catastrophically on a near-miss path and could hang the gate for an entire repo
|
|
2518
|
+
* (a manifest glob with many non-adjacent `*`). This algorithm is O(path × parts) with NO backtracking.
|
|
2519
|
+
*/
|
|
2520
|
+
function linearGlobMatcher(pattern) {
|
|
2521
|
+
// The caller only compiles this for a pattern that contains a wildcard, so split always yields >= 2 parts.
|
|
2522
|
+
const parts = pattern.split(/\*+/); // literal segments between (collapsed) wildcard runs
|
|
2523
|
+
const first = parts[0];
|
|
2524
|
+
const last = parts[parts.length - 1];
|
|
2525
|
+
const middles = parts.slice(1, -1).filter((part) => part.length > 0);
|
|
2526
|
+
return (path) => {
|
|
2527
|
+
if (!path.startsWith(first) || !path.endsWith(last))
|
|
2528
|
+
return false;
|
|
2529
|
+
let idx = first.length;
|
|
2530
|
+
for (const part of middles) {
|
|
2531
|
+
const found = path.indexOf(part, idx);
|
|
2532
|
+
if (found === -1)
|
|
2533
|
+
return false;
|
|
2534
|
+
idx = found + part.length;
|
|
2535
|
+
}
|
|
2536
|
+
return path.length - last.length >= idx; // the suffix must not overlap the consumed prefix/middles
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Compile a manifest path pattern into a predicate over an ALREADY-normalized path. Supports exact paths,
|
|
2541
|
+
* directory prefixes (`src/` or `src`), and `*` wildcards (`*` and a double-star both match any run of chars
|
|
2542
|
+
* across `/`). A double-star-then-separator prefix means "zero or more path segments", so the mandatory slash
|
|
2543
|
+
* is absorbed and a double-star glob also matches a ROOT-level (zero-depth) file, not only nested ones.
|
|
2544
|
+
* Compiling once lets a caller test many paths against one pattern without recompiling per path.
|
|
2545
|
+
* An empty/blank pattern never matches.
|
|
2546
|
+
*/
|
|
2547
|
+
function expandGlobstarSlash(pattern) {
|
|
2548
|
+
const alternatives = [""];
|
|
2549
|
+
for (let idx = 0; idx < pattern.length;) {
|
|
2550
|
+
if (pattern.startsWith("**/", idx)) {
|
|
2551
|
+
const count = alternatives.length;
|
|
2552
|
+
const canKeepRootAlternatives = count * 2 <= MAX_GLOBSTAR_SLASH_ALTERNATIVES;
|
|
2553
|
+
for (let altIdx = count - 1; altIdx >= 0; altIdx -= 1) {
|
|
2554
|
+
const prefix = alternatives[altIdx];
|
|
2555
|
+
alternatives[altIdx] = `${prefix}*/`;
|
|
2556
|
+
if (canKeepRootAlternatives)
|
|
2557
|
+
alternatives.push(prefix);
|
|
2558
|
+
}
|
|
2559
|
+
idx += 3;
|
|
2560
|
+
continue;
|
|
2561
|
+
}
|
|
2562
|
+
for (let altIdx = 0; altIdx < alternatives.length; altIdx += 1)
|
|
2563
|
+
alternatives[altIdx] += pattern[idx];
|
|
2564
|
+
idx += 1;
|
|
2565
|
+
}
|
|
2566
|
+
return alternatives;
|
|
2567
|
+
}
|
|
2568
|
+
function compileManifestPathMatcher(pattern) {
|
|
2569
|
+
const normalizedPattern = normalizePathForMatch(pattern);
|
|
2570
|
+
if (!normalizedPattern)
|
|
2571
|
+
return () => false;
|
|
2572
|
+
if (normalizedPattern.includes("*")) {
|
|
2573
|
+
// `**/` means zero or more whole path segments. Keep the slash in the non-root alternative so
|
|
2574
|
+
// basename globs (e.g. `**/safe.ts`) do not degrade into suffix globs that match `unsafe.ts`.
|
|
2575
|
+
const matchers = expandGlobstarSlash(normalizedPattern).map((globbed) => globbed.includes("*") ? linearGlobMatcher(globbed) : (normalizedPath) => normalizedPath === globbed);
|
|
2576
|
+
return (normalizedPath) => matchers.some((matcher) => matcher(normalizedPath));
|
|
2577
|
+
}
|
|
2578
|
+
const dirPattern = normalizedPattern.endsWith("/") ? normalizedPattern : `${normalizedPattern}/`;
|
|
2579
|
+
return (normalizedPath) => normalizedPath === normalizedPattern || normalizedPath.startsWith(dirPattern);
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Match a changed path against a manifest path pattern. Supports exact paths, directory
|
|
2583
|
+
* prefixes (`src/` or `src`), and `*` wildcards (`**` collapses to `*`).
|
|
2584
|
+
*/
|
|
2585
|
+
export function matchesManifestPath(path, pattern) {
|
|
2586
|
+
const normalizedPath = normalizePathForMatch(path);
|
|
2587
|
+
if (!normalizedPath)
|
|
2588
|
+
return false;
|
|
2589
|
+
return compileManifestPathMatcher(pattern)(normalizedPath);
|
|
2590
|
+
}
|
|
2591
|
+
export function compileFocusManifestPolicy(repoFullNameOrManifest, manifestOrOptions, options = {}) {
|
|
2592
|
+
let repoFullName;
|
|
2593
|
+
let manifest;
|
|
2594
|
+
if (typeof repoFullNameOrManifest === "string") {
|
|
2595
|
+
repoFullName = repoFullNameOrManifest;
|
|
2596
|
+
manifest = manifestOrOptions;
|
|
2597
|
+
}
|
|
2598
|
+
else {
|
|
2599
|
+
repoFullName = "";
|
|
2600
|
+
manifest = repoFullNameOrManifest;
|
|
2601
|
+
options = manifestOrOptions ?? {};
|
|
2602
|
+
}
|
|
2603
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
2604
|
+
const safePublicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe);
|
|
2605
|
+
const contributionLanes = buildPolicyContributionLanes(manifest);
|
|
2606
|
+
const readinessWarnings = buildPolicyReadinessWarnings(manifest);
|
|
2607
|
+
const entryGuidance = buildPolicyEntryGuidance(manifest);
|
|
2608
|
+
const summary = buildPolicySummary(manifest);
|
|
2609
|
+
return {
|
|
2610
|
+
repoFullName,
|
|
2611
|
+
generatedAt,
|
|
2612
|
+
source: manifest.source,
|
|
2613
|
+
present: manifest.present,
|
|
2614
|
+
publicSafe: {
|
|
2615
|
+
contributionLanes,
|
|
2616
|
+
labelPolicy: {
|
|
2617
|
+
preferredLabels: manifest.preferredLabels.filter(isFocusManifestPublicSafe),
|
|
2618
|
+
required: manifest.linkedIssuePolicy !== "optional",
|
|
2619
|
+
},
|
|
2620
|
+
validation: {
|
|
2621
|
+
expectations: manifest.testExpectations.filter(isFocusManifestPublicSafe),
|
|
2622
|
+
linkedIssuePolicy: manifest.linkedIssuePolicy,
|
|
2623
|
+
},
|
|
2624
|
+
issueDiscoveryPolicy: manifest.issueDiscoveryPolicy,
|
|
2625
|
+
publicNotes: safePublicNotes,
|
|
2626
|
+
readinessWarnings,
|
|
2627
|
+
entryGuidance,
|
|
2628
|
+
summary,
|
|
2629
|
+
},
|
|
2630
|
+
authenticated: {
|
|
2631
|
+
manifestSource: manifest.source,
|
|
2632
|
+
privateNoteCount: manifest.maintainerNotes.length,
|
|
2633
|
+
manifestWarningCount: manifest.warnings.length,
|
|
2634
|
+
parseWarnings: manifest.warnings,
|
|
2635
|
+
readinessWarnings,
|
|
2636
|
+
maintainerContext: manifest.maintainerNotes,
|
|
2637
|
+
},
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
function buildPolicyEntryGuidance(manifest) {
|
|
2641
|
+
const guidance = [];
|
|
2642
|
+
// Build the sentence from the public-safe subset (as preferredLabels and publicNotes below already do, and
|
|
2643
|
+
// as the sibling buildPolicyContributionLanes does for preferredPaths). Joining the raw wantedPaths means a
|
|
2644
|
+
// single reserved-word path (e.g. `src/ranking/`) fails the all-or-nothing public-safety filter at the end
|
|
2645
|
+
// and silently drops the entire focus-areas guidance line instead of surfacing the safe paths.
|
|
2646
|
+
const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe);
|
|
2647
|
+
if (safeWantedPaths.length > 0) {
|
|
2648
|
+
guidance.push(`Focus changes on maintainer-wanted areas: ${safeWantedPaths.slice(0, 5).join(", ")}.`);
|
|
2649
|
+
}
|
|
2650
|
+
if (manifest.linkedIssuePolicy === "required")
|
|
2651
|
+
guidance.push("Link a tracked issue before opening a pull request.");
|
|
2652
|
+
else if (manifest.linkedIssuePolicy === "preferred")
|
|
2653
|
+
guidance.push("Linking a tracked issue is preferred before opening a pull request.");
|
|
2654
|
+
if (manifest.preferredLabels.length > 0) {
|
|
2655
|
+
const safeLabels = manifest.preferredLabels.filter(isFocusManifestPublicSafe);
|
|
2656
|
+
if (safeLabels.length > 0)
|
|
2657
|
+
guidance.push(`Apply a maintainer-preferred label: ${safeLabels.slice(0, 3).join(", ")}.`);
|
|
2658
|
+
}
|
|
2659
|
+
guidance.push(...manifest.publicNotes.filter(isFocusManifestPublicSafe));
|
|
2660
|
+
return [...new Set(guidance)].filter(isFocusManifestPublicSafe);
|
|
2661
|
+
}
|
|
2662
|
+
function buildPolicySummary(manifest) {
|
|
2663
|
+
if (!manifest.present)
|
|
2664
|
+
return "No maintainer focus manifest; contribution guidance is not constrained.";
|
|
2665
|
+
if (manifest.issueDiscoveryPolicy === "encouraged")
|
|
2666
|
+
return "Issue-discovery is the preferred contribution mode for this repo.";
|
|
2667
|
+
if (manifest.issueDiscoveryPolicy === "discouraged")
|
|
2668
|
+
return "Direct PRs are preferred; issue-discovery submissions are discouraged.";
|
|
2669
|
+
if (manifest.wantedPaths.length > 0)
|
|
2670
|
+
return "Direct PRs on the maintainer-wanted areas are preferred.";
|
|
2671
|
+
return "Contribution guidance is derived from the maintainer focus manifest.";
|
|
2672
|
+
}
|
|
2673
|
+
function buildPolicyContributionLanes(manifest) {
|
|
2674
|
+
if (!manifest.present)
|
|
2675
|
+
return [];
|
|
2676
|
+
const lanes = [];
|
|
2677
|
+
const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe);
|
|
2678
|
+
const safeTestExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe);
|
|
2679
|
+
// Derive the public preference only from public-safe signals: use the SAME filtered list that surfaces in
|
|
2680
|
+
// validationExpectations below, not the raw testExpectations. Otherwise a manifest whose only test expectation is
|
|
2681
|
+
// public-unsafe (e.g. a wallet/seed phrase) is redacted from the lane yet still flips the public preference to
|
|
2682
|
+
// "preferred" ("…with required validation evidence"), a self-contradictory verdict with no visible basis.
|
|
2683
|
+
const directPrPreference = manifest.issueDiscoveryPolicy === "encouraged" ? "discouraged"
|
|
2684
|
+
: safeWantedPaths.length > 0 || safeTestExpectations.length > 0 ? "preferred"
|
|
2685
|
+
: "neutral";
|
|
2686
|
+
lanes.push({
|
|
2687
|
+
id: "direct-pr",
|
|
2688
|
+
preference: directPrPreference,
|
|
2689
|
+
title: "Direct pull request lane",
|
|
2690
|
+
summary: directPrPreference === "discouraged"
|
|
2691
|
+
? "Direct pull requests are discouraged; issue discovery is the preferred entry mode."
|
|
2692
|
+
: directPrPreference === "preferred"
|
|
2693
|
+
? "Contribute changes in maintainer-wanted areas with required validation evidence."
|
|
2694
|
+
: "Direct pull requests are accepted when they stay inside maintainer-wanted scope.",
|
|
2695
|
+
preferredPaths: safeWantedPaths,
|
|
2696
|
+
discouragedPaths: [],
|
|
2697
|
+
validationExpectations: safeTestExpectations,
|
|
2698
|
+
publicNotes: manifest.publicNotes.filter(isFocusManifestPublicSafe),
|
|
2699
|
+
});
|
|
2700
|
+
const issueDiscoveryPreference = manifest.issueDiscoveryPolicy === "encouraged" ? "preferred"
|
|
2701
|
+
: manifest.issueDiscoveryPolicy === "discouraged" ? "discouraged"
|
|
2702
|
+
: "neutral";
|
|
2703
|
+
lanes.push({
|
|
2704
|
+
id: "issue-discovery",
|
|
2705
|
+
preference: issueDiscoveryPreference,
|
|
2706
|
+
title: "Issue discovery lane",
|
|
2707
|
+
summary: issueDiscoveryPreference === "preferred"
|
|
2708
|
+
? "File well-scoped issue reports that the maintainer has indicated are welcome."
|
|
2709
|
+
: issueDiscoveryPreference === "discouraged"
|
|
2710
|
+
? "The maintainer has indicated this repo prefers direct fixes over new issue reports."
|
|
2711
|
+
: "Issue discovery is optional; confirm maintainer scope before filing new issues.",
|
|
2712
|
+
preferredPaths: [],
|
|
2713
|
+
discouragedPaths: [],
|
|
2714
|
+
validationExpectations: [],
|
|
2715
|
+
publicNotes: [],
|
|
2716
|
+
});
|
|
2717
|
+
return lanes;
|
|
2718
|
+
}
|
|
2719
|
+
function buildPolicyReadinessWarnings(manifest) {
|
|
2720
|
+
if (!manifest.present)
|
|
2721
|
+
return [];
|
|
2722
|
+
const warnings = [];
|
|
2723
|
+
if (manifest.wantedPaths.length === 0 && manifest.preferredLabels.length === 0) {
|
|
2724
|
+
warnings.push("Focus manifest does not define wanted paths or preferred labels; contribution scope may be unclear to contributors.");
|
|
2725
|
+
}
|
|
2726
|
+
if (manifest.testExpectations.length === 0) {
|
|
2727
|
+
warnings.push("Focus manifest does not define validation expectations; contributors may not know what tests to run.");
|
|
2728
|
+
}
|
|
2729
|
+
return warnings.filter(isFocusManifestPublicSafe);
|
|
2730
|
+
}
|