diff --git a/.gittensory.yml b/.gittensory.yml index e1825a7c4c..5314c48262 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -55,6 +55,13 @@ gate: # relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) | # openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult +# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a +# PR generating AGENTS.md/CLAUDE.md from this repo's own profile. +# repoDocGeneration: +# enabled: true # default false — must be explicitly turned on per repo +# scope: [agents] # agents | skills — which generated file types are in play +# allowOverwriteExisting: false # required before Gittensory will touch an existing hand-maintained file + publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. - Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3a8420807..404986ba27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -278,6 +278,13 @@ Config as code (`.gittensory.yml`) — every repository setting is controllable `fields: { : false }` to show/hide individual panel rows (`linkedIssue`, `relatedWork`, `reviewLoad`, `validationEvidence`, `openPrQueue`, `contributorContext`, `gateResult`). Maintainer text that fails the public-safe filter (reward/score/wallet/hotkey/etc.) is dropped, never published. +- **`repoDocGeneration:`** opts a repo into the AGENTS.md/CLAUDE.md generation roadmap (#2993) — a + `.gittensory.yml`-only surface with no dashboard/DB counterpart. `enabled` (default `false`) turns it on; + `scope` (default `["agents"]`) picks which generated file types are in play (`"agents"` for + AGENTS.md/CLAUDE.md, plus `"skills"` once skill-file generation lands); `allowOverwriteExisting` (default + `false`) is a separate opt-in required before the engine proposes an overwrite for a repo that already has + a hand-maintained AGENTS.md/CLAUDE.md — absent it, an existing hand-written file is left alone and + generation is skipped. - Precedence: `.gittensory.yml` `gate:` > `.gittensory.yml` `settings:` > dashboard repository settings > safe defaults; unset fields fall back to the next layer. The committed root `.gittensory.yml` is the worked example. Resolved once in `resolveRepositorySettings`, so the whole app honours the file. diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index c480e3d90d..183df583ab 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -59,6 +59,13 @@ gate: # relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) | # openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult +# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a +# PR generating AGENTS.md/CLAUDE.md from this repo's own profile. +# repoDocGeneration: +# enabled: true # default false — must be explicitly turned on per repo +# scope: [agents] # agents | skills — which generated file types are in play +# allowOverwriteExisting: false # required before Gittensory will touch an existing hand-maintained file + publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. - Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics. diff --git a/src/github/repo-doc-pr.ts b/src/github/repo-doc-pr.ts index 0d36331266..6153b1bfab 100644 --- a/src/github/repo-doc-pr.ts +++ b/src/github/repo-doc-pr.ts @@ -10,9 +10,17 @@ // generated section is unchanged gets NO pull request at all (no-op), a repo whose generated section changed // gets a pull request with everything outside the markers preserved byte-for-byte, and a repo whose marker // block is missing or malformed gets neither a silent overwrite nor a guess -- just a reported reason. +// +// CONFIG-AS-CODE GATE (#3002): this whole feature is opt-in per repo via `.gittensory.yml repoDocGeneration:` +// (src/signals/focus-manifest.ts) -- a manifest-only surface with no DB-backed counterpart, since there is no +// dashboard toggle for it. `enabled`/`scope` are checked BEFORE any profile extraction or GitHub call (the +// common case is disabled, so this must be cheap); `allowOverwriteExisting` is checked later, once refresh +// reports `manual-review-required` (the "this file looks hand-maintained" signal), and lets that specific case +// proceed as a fresh wholesale generate instead of skipping. import { githubErrorStatus, withInstallationTokenRetry } from "./app"; import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client"; import { getRepository } from "../db/repositories"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { extractRepoProfile } from "../review/repo-profile"; import { REPO_DOC_MARKERS, renderRepoDocContent } from "../review/repo-doc-render"; import { refreshGeneratedDoc } from "../review/generated-doc-refresh"; @@ -96,7 +104,7 @@ Every fact above was read directly from this repository, not templated or guesse ## Opting out -Disable repo-doc generation for this repository, or simply close this pull request -- no further action is taken until it is re-enabled. +Set \`repoDocGeneration.enabled: false\` in this repository's \`.gittensory.yml\` (or simply close this pull request) -- no further action is taken until it is re-enabled. `; } @@ -117,6 +125,10 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod const repository = await getRepository(env, repoFullName); if (!repository?.installationId) return { opened: false, reason: "repository is not installed" }; + const manifest = await loadRepoFocusManifest(env, repoFullName); + if (!manifest.repoDocGeneration.enabled) return { opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" }; + if (!manifest.repoDocGeneration.scope.includes("agents")) return { opened: false, reason: 'repo-doc generation scope does not include "agents" for this repository (.gittensory.yml repoDocGeneration.scope)' }; + const profile = await extractRepoProfile(env, repoFullName); if (!profile.present) return { opened: false, reason: profile.reason }; const generatedSection = renderRepoDocContent(profile); @@ -141,9 +153,16 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod if (existing) return { opened: true, reused: true, pullNumber: existing.number, url: existing.html_url, claudeMode: "unknown" }; const currentAgentsContent = await fetchExistingAgentsMdContent(octokit, owner, repo, baseBranch); - const refresh = refreshGeneratedDoc(currentAgentsContent, generatedSection, REPO_DOC_MARKERS); + let refresh = refreshGeneratedDoc(currentAgentsContent, generatedSection, REPO_DOC_MARKERS); + if (refresh.action === "manual-review-required") { + // "manual-review-required" is generated-doc-refresh.ts's proxy for "this file looks hand-maintained, + // not machine-generated" (no recognizable marker block). #3002's allowOverwriteExisting is the explicit + // opt-in required before that content is discarded in favor of a fresh generate -- without it, stay + // skipped exactly as #3004 already behaves. + if (!manifest.repoDocGeneration.allowOverwriteExisting) return { opened: false, reason: `AGENTS.md needs manual review before it can be refreshed: ${refresh.reason}` }; + refresh = { action: "generate", content: generatedSection }; + } if (refresh.action === "no-change") return { opened: false, reason: "no meaningful change since the last generated AGENTS.md" }; - if (refresh.action === "manual-review-required") return { opened: false, reason: `AGENTS.md needs manual review before it can be refreshed: ${refresh.reason}` }; const agentsContent = refresh.content; const branchInfo = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", { owner, repo, branch: baseBranch }); diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index a1499033bb..8a26e96573 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -1,7 +1,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { contentLaneConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; +import { contentLaneConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML, resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest"; export const REPO_FOCUS_MANIFEST_SIGNAL = "repo-focus-manifest"; @@ -283,6 +283,7 @@ function manifestToJson(manifest: FocusManifest): Record { review: reviewConfigToJson(manifest.review), features: featuresConfigToJson(manifest.features), contentLane: contentLaneConfigToJson(manifest.contentLane), + repoDocGeneration: repoDocGenerationConfigToJson(manifest.repoDocGeneration), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 2abebe8a20..0d5ca12f20 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -149,6 +149,28 @@ export type FocusManifestContentLaneConfig = { validatorId: string | null; }; +/** Which generated-file types the repo-doc generation roadmap (#2993) is allowed to touch for a repo. + * "agents" covers AGENTS.md/CLAUDE.md (#3000/#3004); "skills" covers generated Claude Code/Codex skill + * files once that generator lands (#3001) -- listed here now so a maintainer can opt in ahead of time. */ +export type FocusManifestRepoDocGenerationScope = "agents" | "skills"; + +/** + * Per-repo opt-in for the repo-doc generation roadmap (#2993/#3002), declared as code under + * `repoDocGeneration:`. Purely a `.gittensory.yml` surface -- there is no DB-backed dashboard counterpart, + * so precedence is simply "the manifest value, or the default below when unset" (no DB layer to overlay). + * Defaults to fully disabled: a repo with no `repoDocGeneration:` block, or an explicit `enabled: false`, + * is never touched by the generator. `allowOverwriteExisting` is a SEPARATE opt-in specifically for a repo + * that already has a hand-maintained AGENTS.md/CLAUDE.md (no recognizable generated-content marker block, + * per generated-doc-refresh.ts's `manual-review-required` outcome) -- without it, that repo is left alone + * rather than proposed for a wholesale overwrite, even when `enabled` is true. + */ +export type FocusManifestRepoDocGenerationConfig = { + present: boolean; + enabled: boolean; + scope: FocusManifestRepoDocGenerationScope[]; + allowOverwriteExisting: boolean; +}; + /** * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here @@ -336,6 +358,7 @@ export type FocusManifest = { review: FocusManifestReviewConfig; features: FocusManifestFeaturesConfig; contentLane: FocusManifestContentLaneConfig; + repoDocGeneration: FocusManifestRepoDocGenerationConfig; warnings: string[]; }; @@ -429,6 +452,13 @@ const EMPTY_CONTENT_LANE_CONFIG: FocusManifestContentLaneConfig = { validatorId: null, }; +const EMPTY_REPO_DOC_GENERATION_CONFIG: FocusManifestRepoDocGenerationConfig = { + present: false, + enabled: false, + scope: ["agents"], + allowOverwriteExisting: false, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -444,6 +474,7 @@ const EMPTY_MANIFEST: FocusManifest = { review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, + repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, warnings: [], }; @@ -473,6 +504,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, + repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, }; } @@ -927,6 +959,53 @@ export function contentLaneConfigToJson(contentLane: FocusManifestContentLaneCon return out; } +const REPO_DOC_GENERATION_SCOPES: readonly FocusManifestRepoDocGenerationScope[] = ["agents", "skills"]; + +/** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error + * and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise + * `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is + * invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */ +function parseRepoDocGenerationScope(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationScope[] { + if (value === undefined || value === null) return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; + if (!Array.isArray(value)) { + warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.'); + return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; + } + const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings); + return raw.filter((entry): entry is FocusManifestRepoDocGenerationScope => { + if ((REPO_DOC_GENERATION_SCOPES as readonly string[]).includes(entry)) return true; + warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`); + return false; + }); +} + +/** + * Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a + * concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the + * parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []` + * is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to + * `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file. + */ +function parseRepoDocGenerationConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationConfig { + if (value === undefined || value === null) return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.'); + return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false; + const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false; + const scope = parseRepoDocGenerationScope(record.scope, warnings); + return { present: true, enabled, scope, allowOverwriteExisting }; +} + +/** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips + * through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */ +export function repoDocGenerationConfigToJson(config: FocusManifestRepoDocGenerationConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting }; +} + function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { if (value === undefined || value === null) return null; if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; @@ -1769,6 +1848,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): review: parseReviewConfig(record.review, warnings), features: parseFeaturesConfig(record.features, warnings), contentLane: parseContentLaneConfig(record.contentLane, warnings), + repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings), warnings, }; if ( @@ -1783,7 +1863,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): Object.keys(manifest.settings).length === 0 && !manifest.review.present && !manifest.features.present && - !manifest.contentLane.present + !manifest.contentLane.present && + !manifest.repoDocGeneration.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 77e2f6bca2..273240e445 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -17,6 +17,7 @@ import { resolveReviewPreMergeChecks, composeRepoReviewContext, resolveReviewPromptOverrides, + repoDocGenerationConfigToJson, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, @@ -526,6 +527,7 @@ describe("compileFocusManifestPolicy", () => { review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, + repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -1264,6 +1266,75 @@ describe("parseFocusManifest gate config", () => { expect(contentLaneConfigToJson(m.contentLane)).toEqual({ entryFileGlob: "registry/*.json", collectionField: "items" }); }); + describe("repoDocGeneration: (#3002, repo-doc generation config-as-code surface)", () => { + it("defaults to fully disabled and absent when the key is omitted, and does not make the manifest present on its own", () => { + const m = parseFocusManifest({}); + expect(m.repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }); + expect(m.present).toBe(false); + }); + + it("treats an explicit null the same as an omitted key", () => { + expect(parseFocusManifest({ repoDocGeneration: null }).repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }); + }); + + it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { + const asString = parseFocusManifest({ repoDocGeneration: "nope" as never }); + expect(asString.repoDocGeneration.present).toBe(false); + expect(asString.warnings.some((w) => /"repoDocGeneration" must be a mapping/.test(w))).toBe(true); + const asArray = parseFocusManifest({ repoDocGeneration: ["nope"] as never }); + expect(asArray.repoDocGeneration.present).toBe(false); + expect(asArray.warnings.some((w) => /"repoDocGeneration" must be a mapping/.test(w))).toBe(true); + }); + + it("parses enabled: true and defaults scope/allowOverwriteExisting, making the manifest present", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true } }); + expect(m.repoDocGeneration).toEqual({ present: true, enabled: true, scope: ["agents"], allowOverwriteExisting: false }); + expect(m.present).toBe(true); + }); + + it("warns and defaults to false when enabled is a non-boolean value", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: "yes" as unknown as boolean } }); + expect(m.repoDocGeneration.enabled).toBe(false); + expect(m.warnings.some((w) => /repoDocGeneration\.enabled/.test(w))).toBe(true); + }); + + it("parses allowOverwriteExisting independently of enabled", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: false, allowOverwriteExisting: true } }); + expect(m.repoDocGeneration).toEqual({ present: true, enabled: false, scope: ["agents"], allowOverwriteExisting: true }); + }); + + it("accepts an explicit multi-entry scope list", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true, scope: ["agents", "skills"] } }); + expect(m.repoDocGeneration.scope).toEqual(["agents", "skills"]); + }); + + it("respects an explicitly empty scope list as 'nothing in scope', rather than defaulting it back to [\"agents\"]", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true, scope: [] } }); + expect(m.repoDocGeneration.scope).toEqual([]); + }); + + it("filters out unrecognized scope entries with a warning, keeping the valid ones", () => { + const m = parseFocusManifest({ repoDocGeneration: { scope: ["agents", "bogus"] } }); + expect(m.repoDocGeneration.scope).toEqual(["agents"]); + expect(m.warnings.some((w) => /repoDocGeneration\.scope.*unrecognized entry "bogus"/.test(w))).toBe(true); + }); + + it("falls back to the default scope (not an empty one) when scope is a non-list type", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true, scope: "agents" as unknown as string[] } }); + expect(m.repoDocGeneration.scope).toEqual(["agents"]); + expect(m.warnings.some((w) => /repoDocGeneration\.scope.*must be a list/.test(w))).toBe(true); + }); + + it("round-trips through repoDocGenerationConfigToJson → parseFocusManifest unchanged", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true, scope: ["agents", "skills"], allowOverwriteExisting: true } }); + expect(parseFocusManifest({ repoDocGeneration: repoDocGenerationConfigToJson(m.repoDocGeneration) }).repoDocGeneration).toEqual(m.repoDocGeneration); + }); + + it("repoDocGenerationConfigToJson returns null for an absent config", () => { + expect(repoDocGenerationConfigToJson(parseFocusManifest(null).repoDocGeneration)).toBeNull(); + }); + }); + it("parses aiReviewAllAuthors from the settings: block (generic override)", () => { const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true , closeOwnerAuthors: false} }); expect(parsed.settings.aiReviewAllAuthors).toBe(true); diff --git a/test/unit/repo-doc-pr.test.ts b/test/unit/repo-doc-pr.test.ts index 100f293755..5fa394a401 100644 --- a/test/unit/repo-doc-pr.test.ts +++ b/test/unit/repo-doc-pr.test.ts @@ -6,6 +6,7 @@ import * as repositoriesModule from "../../src/db/repositories"; import * as repoDocRenderModule from "../../src/review/repo-doc-render"; import { renderRepoDocContent } from "../../src/review/repo-doc-render"; import { extractRepoProfile } from "../../src/review/repo-profile"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; function base64Utf8(text: string): string { @@ -36,6 +37,18 @@ async function seedInstalledRepo(env: ReturnType, options: await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, ...(options.defaultBranch !== undefined ? { default_branch: options.defaultBranch } : {}) }, 555); } +// #3002: repo-doc generation is opt-in per repo via .gittensory.yml `repoDocGeneration:` -- defaults to fully +// disabled, so every test exercising behavior PAST that gate needs it explicitly enabled. Defaults `enabled` to +// true here (the common case for these tests) while letting callers override scope/allowOverwriteExisting. +async function seedRepoDocGenerationConfig(env: ReturnType, repoFullName: string, overrides: { enabled?: boolean; scope?: string[]; allowOverwriteExisting?: boolean } = {}): Promise { + await upsertRepoFocusManifest(env, repoFullName, { repoDocGeneration: { enabled: true, ...overrides } }); +} + +// A fetch stub matching every candidate raw-content URL loadRepoFocusManifest's live fetcher tries when there is +// no persisted manifest snapshot -- returning a plain 404-shaped failure degrades it to the default (disabled) +// manifest, matching fetchRepoFocusManifestFile's own fail-safe "try the next candidate, then give up" behavior. +const MANIFEST_RAW_CONTENT_URL = /raw\.githubusercontent\.com/; + const TOKEN_URL = /\/access_tokens$/; describe("openRepoDocPullRequest (#3000)", () => { @@ -56,9 +69,41 @@ describe("openRepoDocPullRequest (#3000)", () => { expect(result).toEqual({ opened: false, reason: "repository is not installed" }); }); + it("#3002: declines by default when repo-doc generation has no .gittensory.yml config at all", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (MANIFEST_RAW_CONTENT_URL.test(url)) return new Response("not found", { status: 404 }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" }); + }); + + it("#3002: declines when explicitly disabled via .gittensory.yml", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO, { enabled: false }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" }); + }); + + it("#3002: declines when enabled but scope excludes \"agents\"", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO, { scope: ["skills"] }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: 'repo-doc generation scope does not include "agents" for this repository (.gittensory.yml repoDocGeneration.scope)' }); + }); + it("declines with the profile's own reason when the repo has no RAG index yet", async () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedRepoDocGenerationConfig(env, REPO); const result = await openRepoDocPullRequest(env, REPO, "live"); expect(result).toEqual({ opened: false, reason: "no RAG index configured or populated for this repo yet" }); }); @@ -67,6 +112,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); vi.spyOn(repoDocRenderModule, "renderRepoDocContent").mockReturnValueOnce(null); const result = await openRepoDocPullRequest(env, REPO, "live"); expect(result).toEqual({ opened: false, reason: "no content rendered from profile" }); @@ -76,6 +122,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); let tokenMinted = false; vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); @@ -91,6 +138,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); const calls: Array<{ method: string; url: string }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -113,6 +161,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); const calls: Array<{ method: string; url: string; body: Record }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -154,6 +203,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); let treeAttempts = 0; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -181,6 +231,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); const calls: string[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -206,6 +257,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); @@ -227,6 +279,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); const profile = await extractRepoProfile(env, REPO); const currentContent = renderRepoDocContent(profile)!; let wroteAnything = false; @@ -248,6 +301,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); @@ -265,6 +319,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); let wroteAnything = false; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -280,10 +335,39 @@ describe("openRepoDocPullRequest (#3000)", () => { expect(wroteAnything).toBe(false); }); + it("#3002: proceeds with a fresh wholesale generate (discarding the old hand-written content) when allowOverwriteExisting is set", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO, { allowOverwriteExisting: true }); + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + const method = init?.method ?? "GET"; + calls.push({ method, url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.includes("/contents/AGENTS.md") && method === "GET") return Response.json({ content: base64Utf8("# Hand-written AGENTS.md\n\nNo markers here.\n"), encoding: "base64" }); + if (url.endsWith("/branches/main")) return Response.json({ commit: { sha: "base-commit-sha", commit: { tree: { sha: "base-tree-sha" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "overwrite-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "overwrite-commit-sha" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 71, html_url: "https://github.com/owner/widgets/pull/71" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 71, url: "https://github.com/owner/widgets/pull/71", claudeMode: "symlink" }); + const treeCall = calls.find((c) => c.url.endsWith("/git/trees")); + const agentsEntry = (treeCall?.body.tree as Array<{ path: string; content: string }>).find((entry) => entry.path === "AGENTS.md"); + expect(agentsEntry?.content).not.toContain("Hand-written AGENTS.md"); + expect(agentsEntry?.content).toContain("# AGENTS.md"); + }); + it("#3004: opens a refresh PR that preserves manual content outside the marker block", async () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); const profile = await extractRepoProfile(env, REPO); const staleGeneratedSection = renderRepoDocContent(profile)!.replace("`npm run lint`", "an older lint command"); const currentContent = `# Preamble the maintainer added.\n\n${staleGeneratedSection}\nAn appendix the maintainer added.\n`; @@ -317,6 +401,7 @@ describe("openRepoDocPullRequest (#3000)", () => { const env = envWithKey(); await seedInstalledRepo(env, { defaultBranch: "main" }); await seedProfileData(env); + await seedRepoDocGenerationConfig(env, REPO); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); @@ -342,6 +427,7 @@ describe("openRepoDocPullRequest (#3000)", () => { it("splits a bare repo name with no owner segment instead of throwing", async () => { const env = envWithKey(); await env.DB.prepare("INSERT INTO repo_chunks (id, project, repo, path, chunk_index, kind, text) VALUES (?,?,?,?,?,?,?)").bind("bare::0", "", "widgets", "src/widget.ts", 0, "code", "export function widget() {}").run(); + await seedRepoDocGenerationConfig(env, "widgets"); vi.spyOn(repositoriesModule, "getRepository").mockResolvedValueOnce({ fullName: "widgets", owner: "",