diff --git a/src/github/repo-doc-pr.ts b/src/github/repo-doc-pr.ts new file mode 100644 index 0000000000..aec6855f62 --- /dev/null +++ b/src/github/repo-doc-pr.ts @@ -0,0 +1,130 @@ +// Repo-doc PR delivery (#3000, part of the repo-doc generation roadmap #2993). Turns a rendered AGENTS.md body +// (src/review/repo-doc-render.ts, itself derived from src/review/repo-profile.ts) into an actual pull request +// against the target repo -- branch + commit + PR-open, reusing the SAME installation-token write chokepoint +// (makeInstallationOctokit) every other GitHub write in this engine goes through. Never a direct commit to the +// target repo's default branch: AGENTS.md and CLAUDE.md are always delivered as a PR, first-run or refresh alike. +import { withInstallationTokenRetry } from "./app"; +import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client"; +import { getRepository } from "../db/repositories"; +import { extractRepoProfile } from "../review/repo-profile"; +import { renderRepoDocContent } from "../review/repo-doc-render"; +import type { AgentActionMode } from "../settings/agent-execution"; + +/** Stable across runs (not per-run unique) so a repeat invocation targets the SAME branch/PR instead of piling up + * duplicates -- #3004's diff-aware refresh is expected to update commits on this same branch rather than open a + * second PR. #3000 itself only needs the "already an open PR on this branch" short-circuit below. */ +const REPO_DOC_BRANCH_NAME = "gittensory/repo-docs"; +const AGENTS_FILE_PATH = "AGENTS.md"; +const CLAUDE_FILE_PATH = "CLAUDE.md"; +const PR_TITLE = "docs: generate AGENTS.md and CLAUDE.md from repo profile"; + +export type RepoDocPullRequestResult = + | { opened: true; reused: boolean; pullNumber: number; url: string; claudeMode: "symlink" | "copy" } + | { opened: false; reason: string }; + +// Non-throwing split (mirrors repo-profile.ts's splitRepoFullName, not pr-actions.ts's throwing splitRepo): +// by the time this runs, `repoFullName` already named a row `getRepository` found, so re-validating its shape +// here would only guard against a state the DB's own invariants already rule out. +function splitRepo(repoFullName: string): { owner: string; repo: string } { + const slash = repoFullName.indexOf("/"); + return slash === -1 ? { owner: "", repo: repoFullName } : { owner: repoFullName.slice(0, slash), repo: repoFullName.slice(slash + 1) }; +} + +type DocTreeEntry = { path: string; mode: "100644" | "120000"; type: "blob"; content: string }; +type Octokit = ReturnType; + +/** Builds the two-file tree (AGENTS.md + CLAUDE.md) atop the branch's current tree in ONE commit, so first-run + * (paths absent) and refresh (paths present) are handled identically -- `base_tree` + explicit per-path entries + * add-or-replace regardless of whether the path previously existed, with no separate "does it exist yet" probe. + * Tries a real symlink (git mode 120000) first; if the target repo/platform rejects that tree, retries with + * CLAUDE.md as a byte-identical regular-file copy of AGENTS.md instead (#3000's own documented fallback). */ +async function buildRepoDocTree(octokit: Octokit, owner: string, repo: string, baseTreeSha: string, agentsContent: string): Promise<{ treeSha: string; claudeMode: "symlink" | "copy" }> { + const agentsEntry: DocTreeEntry = { path: AGENTS_FILE_PATH, mode: "100644", type: "blob", content: agentsContent }; + try { + const symlinkEntry: DocTreeEntry = { path: CLAUDE_FILE_PATH, mode: "120000", type: "blob", content: AGENTS_FILE_PATH }; + const response = await octokit.request("POST /repos/{owner}/{repo}/git/trees", { owner, repo, base_tree: baseTreeSha, tree: [agentsEntry, symlinkEntry] }); + return { treeSha: (response.data as { sha: string }).sha, claudeMode: "symlink" }; + } catch { + const copyEntry: DocTreeEntry = { path: CLAUDE_FILE_PATH, mode: "100644", type: "blob", content: agentsContent }; + const response = await octokit.request("POST /repos/{owner}/{repo}/git/trees", { owner, repo, base_tree: baseTreeSha, tree: [agentsEntry, copyEntry] }); + return { treeSha: (response.data as { sha: string }).sha, claudeMode: "copy" }; + } +} + +function repoDocPullRequestBody(repoFullName: string): string { + return `Gittensory opened this pull request on the maintainer's behalf. This is an automated maintenance action, not a manual code review. + +## What this is + +\`AGENTS.md\`, generated from a profile of ${repoFullName}'s own code -- its indexed file layout, naming and test-file conventions, build/test/lint commands, and contribution-workflow settings (whether CI publishes a required check, the linked-issue policy, and indexed CI workflow files). \`CLAUDE.md\` is kept in sync with it (as a symlink where the platform supports one, otherwise an identical copy), so the two never drift apart. + +## Why it looks like this + +Every fact above was read directly from this repository, not templated or guessed. If something looks wrong, it most likely means the underlying signal doesn't represent this repo well -- edit the generated file directly on this branch (or after merging) rather than filing an issue against Gittensory. + +## 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. +`; +} + +/** + * Generate AGENTS.md/CLAUDE.md from this repo's profile and open (or find the already-open) pull request + * carrying them. Returns `{ opened: false, reason }` -- never throws -- when: the repo isn't installed, the repo + * profile has no data yet (#2999's fail-closed branch), `mode` is not `"live"` (dry-run/paused instances must not + * chain several dependent GitHub writes through synthetic suppressed responses -- see `maybeEscalateModeration` + * in `agent-action-executor.ts` for the same "no side effect for a write that didn't really happen" guard on a + * different action), or any step failed partway through. The ENTIRE body runs inside one try/catch (not just the + * GitHub-write chain) so a failure in the repo/profile lookups themselves is reported the same honest way, + * rather than propagating as an uncaught exception from what the rest of the engine treats as a fail-safe call. + */ +export async function openRepoDocPullRequest(env: Env, repoFullName: string, mode: AgentActionMode): Promise { + try { + const repository = await getRepository(env, repoFullName); + if (!repository?.installationId) return { opened: false, reason: "repository is not installed" }; + + const profile = await extractRepoProfile(env, repoFullName); + if (!profile.present) return { opened: false, reason: profile.reason }; + const agentsContent = renderRepoDocContent(profile); + if (!agentsContent) return { opened: false, reason: "no content rendered from profile" }; + + if (mode !== "live") return { opened: false, reason: `repo-doc pull request not opened: action mode is "${mode}"` }; + + const { owner, repo } = splitRepo(repoFullName); + const installationId = repository.installationId; + return await withInstallationTokenRetry(env, installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, mode, githubRateLimitAdmissionKeyForInstallation(installationId)); + + const baseBranch = repository.defaultBranch ?? (await octokit.request("GET /repos/{owner}/{repo}", { owner, repo })).data.default_branch; + + const existingOpenPrs = await octokit.request("GET /repos/{owner}/{repo}/pulls", { owner, repo, state: "open", head: `${owner}:${REPO_DOC_BRANCH_NAME}`, base: baseBranch }); + const existing = (existingOpenPrs.data as Array<{ number: number; html_url: string }>)[0]; + if (existing) return { opened: true, reused: true, pullNumber: existing.number, url: existing.html_url, claudeMode: "symlink" }; + + const branchInfo = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", { owner, repo, branch: baseBranch }); + const baseCommitSha = branchInfo.data.commit.sha; + const baseTreeSha = branchInfo.data.commit.commit.tree.sha; + + const { treeSha, claudeMode } = await buildRepoDocTree(octokit, owner, repo, baseTreeSha, agentsContent); + + const commit = await octokit.request("POST /repos/{owner}/{repo}/git/commits", { owner, repo, message: PR_TITLE, tree: treeSha, parents: [baseCommitSha] }); + const commitSha = (commit.data as { sha: string }).sha; + + await octokit.request("POST /repos/{owner}/{repo}/git/refs", { owner, repo, ref: `refs/heads/${REPO_DOC_BRANCH_NAME}`, sha: commitSha }); + + const pr = await octokit.request("POST /repos/{owner}/{repo}/pulls", { + owner, + repo, + title: PR_TITLE, + body: repoDocPullRequestBody(repoFullName), + head: REPO_DOC_BRANCH_NAME, + base: baseBranch, + maintainer_can_modify: true, + }); + const prData = pr.data as { number: number; html_url: string }; + return { opened: true, reused: false, pullNumber: prData.number, url: prData.html_url, claudeMode }; + }); + } catch (error) { + return { opened: false, reason: error instanceof Error ? error.message : "unknown error opening repo-doc pull request" }; + } +} diff --git a/src/review/repo-doc-render.ts b/src/review/repo-doc-render.ts new file mode 100644 index 0000000000..f47da56901 --- /dev/null +++ b/src/review/repo-doc-render.ts @@ -0,0 +1,103 @@ +// Repo-doc content rendering (#3000, part of the repo-doc generation roadmap #2993). Turns a `RepoProfile` +// (src/review/repo-profile.ts) into the markdown body of a generated AGENTS.md. Pure and deterministic: no +// GitHub calls, no AI, no timestamps besides the one already carried on the profile -- the PR-delivery module +// (src/github/repo-doc-pr.ts) owns everything about HOW the rendered content reaches a repo. +// +// FAILS CLOSED WITH THE PROFILE: a `present: false` profile (no RAG index yet) renders nothing (`null`), mirroring +// #2999's own fail-closed design -- there is no partial or placeholder AGENTS.md, only a real one or none at all. +import type { RepoProfile, RepoProfileCommands, RepoProfileFileNamingStyle, RepoProfileTestFileConvention } from "./repo-profile"; + +/** Bumped whenever the RENDERED CONTENT's structure changes in a way #3004's diff-aware refresh needs to know + * about (new section, reordered section, changed marker) -- not on copy-only wording tweaks. */ +export const REPO_DOC_TEMPLATE_VERSION = 1; + +/** HTML-comment marker embedded in every generated AGENTS.md, mirroring the PR-panel marker convention + * (src/github/comments.ts's `PR_PANEL_COMMENT_MARKER`) so a future diff-aware refresh (#3004) can recognize + * "this file was machine-generated by Gittensory" without depending on exact prose. */ +export const REPO_DOC_CONTENT_MARKER = ``; + +const MAX_RENDERED_TOP_LEVEL_DIRECTORIES = 12; + +const FILE_NAMING_STYLE_LABELS: Record = { + "kebab-case": "kebab-case (`my-file.ts`)", + camelCase: "camelCase (`myFile.ts`)", + snake_case: "snake_case (`my_file.ts`)", + PascalCase: "PascalCase (`MyFile.ts`)", + mixed: "mixed -- no single dominant style detected", + unknown: "not detected", +}; + +const TEST_FILE_CONVENTION_LABELS: Record = { + "dot-test-suffix": "`*.test.*` files", + "dot-spec-suffix": "`*.spec.*` files", + "tests-directory": "a `tests/`/`__tests__/` directory", + "none-detected": "not detected", +}; + +function renderCommandList(label: string, commands: string[], packageManager: RepoProfileCommands["packageManager"]): string { + if (commands.length === 0) return `- ${label}: none detected`; + const runner = packageManager ?? "npm"; + const items = commands.map((name) => `\`${runner} run ${name}\``).join(", "); + return `- ${label}: ${items}`; +} + +function renderTopLevelDirectories(profile: Extract): string { + const entries = profile.architecture.topLevelDirectories.slice(0, MAX_RENDERED_TOP_LEVEL_DIRECTORIES); + const lines = entries.map((entry) => `- \`${entry.path}\` -- ${entry.fileCount} file${entry.fileCount === 1 ? "" : "s"}`); + const omitted = profile.architecture.topLevelDirectories.length - entries.length; + if (omitted > 0) lines.push(`- (${omitted} more, not shown)`); + return lines.join("\n"); +} + +function renderCiWorkflowFiles(ciWorkflowFiles: string[]): string { + if (ciWorkflowFiles.length === 0) return "- none indexed"; + return ciWorkflowFiles.map((path) => `- \`${path}\``).join("\n"); +} + +/** + * Render the markdown body of a generated AGENTS.md from a repo profile, or `null` when the profile has no data + * (`present: false`) -- callers must treat `null` as "do not generate", not as an empty-but-valid document. + */ +export function renderRepoDocContent(profile: RepoProfile): string | null { + if (!profile.present) return null; + const { architecture, conventions, commands, contributionWorkflow } = profile; + return `# AGENTS.md + +${REPO_DOC_CONTENT_MARKER} + +This file is generated by [Gittensory](https://gittensory.aethereal.dev) from a profile of this repository's own +code -- it is not hand-written and not a generic template. If a fact below doesn't fit, edit this file directly. + +## Architecture + +${architecture.indexedFileCount} indexed source file${architecture.indexedFileCount === 1 ? "" : "s"} across ${architecture.topLevelDirectories.length} top-level director${architecture.topLevelDirectories.length === 1 ? "y" : "ies"}: + +${renderTopLevelDirectories(profile)} + +## Conventions + +- File naming: ${FILE_NAMING_STYLE_LABELS[conventions.fileNamingStyle]} +- Test files: ${TEST_FILE_CONVENTION_LABELS[conventions.testFileConvention]} + +## Commands + +- Package manager: ${commands.packageManager ?? "not detected"} +${renderCommandList("Build", commands.buildCommands, commands.packageManager)} +${renderCommandList("Test", commands.testCommands, commands.packageManager)} +${renderCommandList("Lint", commands.lintCommands, commands.packageManager)} + +## Contribution workflow + +- CI publishes a required check: ${contributionWorkflow.gatePublishesCheck ? "yes" : "no"} +- Linked-issue policy: ${contributionWorkflow.linkedIssuePolicy} +- Requires a linked issue: ${contributionWorkflow.requireLinkedIssue ? "yes" : "no"} +- CI workflow files: + +${renderCiWorkflowFiles(contributionWorkflow.ciWorkflowFiles)} + +--- + +Generated ${profile.generatedAt} from this repository's own indexed code. Regenerating replaces this file's +content but never the rest of the repository. +`; +} diff --git a/test/unit/repo-doc-pr.test.ts b/test/unit/repo-doc-pr.test.ts new file mode 100644 index 0000000000..2d549a6f6e --- /dev/null +++ b/test/unit/repo-doc-pr.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +import { openRepoDocPullRequest } from "../../src/github/repo-doc-pr"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import * as repositoriesModule from "../../src/db/repositories"; +import * as repoDocRenderModule from "../../src/review/repo-doc-render"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; +const [PROJECT, CHUNK_REPO] = ["owner", "widgets"]; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +function envWithKey() { + return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); +} + +async function seedChunk(env: ReturnType, path: string, text: string): Promise { + await env.DB.prepare("INSERT INTO repo_chunks (id, project, repo, path, chunk_index, kind, text) VALUES (?,?,?,?,?,?,?)").bind(`${path}::0`, PROJECT, CHUNK_REPO, path, 0, "code", text).run(); +} + +async function seedProfileData(env: ReturnType): Promise { + await seedChunk(env, "src/widget.ts", "export function widget() {}"); + await seedChunk(env, "package.json", JSON.stringify({ scripts: { build: "tsc", test: "vitest run", lint: "eslint ." } })); +} + +async function seedInstalledRepo(env: ReturnType, options: { defaultBranch?: string } = {}): Promise { + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, ...(options.defaultBranch !== undefined ? { default_branch: options.defaultBranch } : {}) }, 555); +} + +const TOKEN_URL = /\/access_tokens$/; + +describe("openRepoDocPullRequest (#3000)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("declines when the repository is not installed", async () => { + const result = await openRepoDocPullRequest(envWithKey(), REPO, "live"); + expect(result).toEqual({ opened: false, reason: "repository is not installed" }); + }); + + it("declines when the repository is installed but carries no installation id", async () => { + const env = envWithKey(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "repository is not installed" }); + }); + + 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" }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "no RAG index configured or populated for this repo yet" }); + }); + + it("declines defensively if content rendering ever returns null for a present profile", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + vi.spyOn(repoDocRenderModule, "renderRepoDocContent").mockReturnValueOnce(null); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "no content rendered from profile" }); + }); + + it("declines without minting an installation token or writing to GitHub when the action mode is not live", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + let tokenMinted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) tokenMinted = true; + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "dry_run"); + expect(result).toEqual({ opened: false, reason: 'repo-doc pull request not opened: action mode is "dry_run"' }); + expect(tokenMinted).toBe(false); + }); + + it("returns the already-open PR without creating a new branch/commit", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + const calls: Array<{ method: string; url: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url }); + if (url.includes("/pulls?") && (init?.method ?? "GET") === "GET") { + return Response.json([{ number: 7, html_url: "https://github.com/owner/widgets/pull/7" }]); + } + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: true, pullNumber: 7, url: "https://github.com/owner/widgets/pull/7", claudeMode: "symlink" }); + expect(calls.some((c) => c.url.includes("/git/trees"))).toBe(false); + }); + + it("opens a first-run pull request with a real CLAUDE.md symlink when the target tree accepts one", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + 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.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: "new-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "new-commit-sha" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({ ref: "refs/heads/gittensory/repo-docs" }); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 42, html_url: "https://github.com/owner/widgets/pull/42" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 42, url: "https://github.com/owner/widgets/pull/42", claudeMode: "symlink" }); + + const treeCall = calls.find((c) => c.url.endsWith("/git/trees")); + expect(treeCall?.body).toMatchObject({ base_tree: "base-tree-sha" }); + const tree = treeCall?.body.tree as Array<{ path: string; mode: string; content: string }>; + expect(tree).toEqual([ + { path: "AGENTS.md", mode: "100644", type: "blob", content: expect.stringContaining("# AGENTS.md") }, + { path: "CLAUDE.md", mode: "120000", type: "blob", content: "AGENTS.md" }, + ]); + + const commitCall = calls.find((c) => c.url.endsWith("/git/commits")); + expect(commitCall?.body).toMatchObject({ tree: "new-tree-sha", parents: ["base-commit-sha"] }); + + const refCall = calls.find((c) => c.url.endsWith("/git/refs")); + expect(refCall?.body).toMatchObject({ ref: "refs/heads/gittensory/repo-docs", sha: "new-commit-sha" }); + + const prCall = calls.find((c) => c.url.endsWith("/repos/owner/widgets/pulls") && c.method === "POST"); + expect(prCall?.body).toMatchObject({ head: "gittensory/repo-docs", base: "main", title: "docs: generate AGENTS.md and CLAUDE.md from repo profile" }); + expect(prCall?.body.body as string).toContain("Gittensory opened this pull request"); + }); + + it("falls back to a byte-identical CLAUDE.md copy when the target repo rejects a symlink tree entry", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + let treeAttempts = 0; + 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"; + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + 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") { + treeAttempts += 1; + if (treeAttempts === 1) return new Response("symlinks unsupported", { status: 422 }); + return Response.json({ sha: "copy-tree-sha" }); + } + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "copy-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: 9, html_url: "https://github.com/owner/widgets/pull/9" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 9, url: "https://github.com/owner/widgets/pull/9", claudeMode: "copy" }); + expect(treeAttempts).toBe(2); + }); + + it("fetches the default branch from GitHub when the stored repository record has none", async () => { + const env = envWithKey(); + await seedInstalledRepo(env); + await seedProfileData(env); + const calls: string[] = []; + 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}`); + if (url.endsWith("/repos/owner/widgets") && method === "GET") return Response.json({ default_branch: "trunk" }); + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.endsWith("/branches/trunk")) return Response.json({ commit: { sha: "c", commit: { tree: { sha: "t" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "ts" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "cs" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 3, html_url: "https://github.com/owner/widgets/pull/3" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 3, url: "https://github.com/owner/widgets/pull/3", claudeMode: "symlink" }); + expect(calls.some((c) => c === "GET https://api.github.com/repos/owner/widgets")).toBe(true); + }); + + it("reports a caught GitHub Error's message when both the symlink and copy tree attempts fail", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + 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"; + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.endsWith("/branches/main")) return Response.json({ commit: { sha: "c", commit: { tree: { sha: "t" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ message: "tree rejected entirely" }, { status: 422 }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result.opened).toBe(false); + expect((result as { opened: false; reason: string }).reason).toMatch(/tree rejected entirely/); + }); + + it("reports a generic message when a non-Error value is rejected partway through", async () => { + const env = envWithKey(); + vi.spyOn(repositoriesModule, "getRepository").mockRejectedValueOnce("a non-Error rejection value"); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "unknown error opening repo-doc pull request" }); + }); + + 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(); + vi.spyOn(repositoriesModule, "getRepository").mockResolvedValueOnce({ + fullName: "widgets", + owner: "", + name: "widgets", + installationId: 555, + isInstalled: true, + isRegistered: false, + isPrivate: false, + defaultBranch: "main", + }); + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + calls.push(url); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, "widgets", "live"); + expect(result.opened).toBe(false); + expect(calls.some((url) => url.includes("/repos//widgets/pulls?"))).toBe(true); + }); +}); diff --git a/test/unit/repo-doc-render.test.ts b/test/unit/repo-doc-render.test.ts new file mode 100644 index 0000000000..a497f8a246 --- /dev/null +++ b/test/unit/repo-doc-render.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { REPO_DOC_CONTENT_MARKER, renderRepoDocContent } from "../../src/review/repo-doc-render"; +import type { RepoProfile, RepoProfileFileNamingStyle, RepoProfileTestFileConvention } from "../../src/review/repo-profile"; +import { REPO_PROFILE_SCHEMA_VERSION } from "../../src/review/repo-profile"; + +function presentProfile(overrides: Partial> = {}): RepoProfile { + return { + version: REPO_PROFILE_SCHEMA_VERSION, + present: true, + repoFullName: "owner/widgets", + generatedAt: "2026-07-04T00:00:00.000Z", + architecture: { indexedFileCount: 42, topLevelDirectories: [{ path: "src", fileCount: 30 }, { path: ".", fileCount: 12 }] }, + conventions: { fileNamingStyle: "kebab-case", testFileConvention: "dot-test-suffix" }, + commands: { packageManager: "npm", buildCommands: ["build"], testCommands: ["test"], lintCommands: ["lint"] }, + contributionWorkflow: { gatePublishesCheck: true, linkedIssuePolicy: "preferred", requireLinkedIssue: false, ciWorkflowFiles: [".github/workflows/ci.yml"] }, + ...overrides, + }; +} + +describe("renderRepoDocContent (#3000)", () => { + it("renders null for an absent profile, never a placeholder document", () => { + const profile: RepoProfile = { version: REPO_PROFILE_SCHEMA_VERSION, present: false, repoFullName: "owner/widgets", generatedAt: "now", reason: "no RAG index configured or populated for this repo yet" }; + expect(renderRepoDocContent(profile)).toBeNull(); + }); + + it("renders the marker, architecture, conventions, commands, and workflow sections for a full profile", () => { + const content = renderRepoDocContent(presentProfile()); + expect(content).not.toBeNull(); + expect(content).toContain(REPO_DOC_CONTENT_MARKER); + expect(content).toContain("# AGENTS.md"); + expect(content).toContain("42 indexed source files across 2 top-level directories"); + expect(content).toContain("- `src` -- 30 files"); + expect(content).toContain("- `.` -- 12 files"); + expect(content).toContain("File naming: kebab-case (`my-file.ts`)"); + expect(content).toContain("Test files: `*.test.*` files"); + expect(content).toContain("Package manager: npm"); + expect(content).toContain("Build: `npm run build`"); + expect(content).toContain("Test: `npm run test`"); + expect(content).toContain("Lint: `npm run lint`"); + expect(content).toContain("CI publishes a required check: yes"); + expect(content).toContain("Linked-issue policy: preferred"); + expect(content).toContain("Requires a linked issue: no"); + expect(content).toContain("- `.github/workflows/ci.yml`"); + expect(content).toContain("Generated 2026-07-04T00:00:00.000Z from this repository's own indexed code."); + }); + + it("uses singular wording for exactly one indexed file and one top-level directory", () => { + const content = renderRepoDocContent(presentProfile({ architecture: { indexedFileCount: 1, topLevelDirectories: [{ path: "src", fileCount: 1 }] } })); + expect(content).toContain("1 indexed source file across 1 top-level directory:"); + expect(content).toContain("- `src` -- 1 file"); + }); + + it("caps the rendered top-level directory list and reports how many were omitted", () => { + const topLevelDirectories = Array.from({ length: 15 }, (_, i) => ({ path: `dir${i}`, fileCount: 15 - i })); + const content = renderRepoDocContent(presentProfile({ architecture: { indexedFileCount: 200, topLevelDirectories } })); + expect(content).toContain("- `dir11` -- 4 files"); + expect(content).not.toContain("`dir12`"); + expect(content).toContain("- (3 more, not shown)"); + }); + + it("degrades to 'none detected' for empty build/test/lint command lists", () => { + const content = renderRepoDocContent(presentProfile({ commands: { packageManager: "npm", buildCommands: [], testCommands: [], lintCommands: [] } })); + expect(content).toContain("- Build: none detected"); + expect(content).toContain("- Test: none detected"); + expect(content).toContain("- Lint: none detected"); + }); + + it("falls back to npm as the runner prefix and 'not detected' label when no package manager is known", () => { + const content = renderRepoDocContent(presentProfile({ commands: { packageManager: null, buildCommands: ["build"], testCommands: [], lintCommands: [] } })); + expect(content).toContain("Package manager: not detected"); + expect(content).toContain("- Build: `npm run build`"); + }); + + it("renders each non-npm package manager as its own run-command prefix", () => { + for (const packageManager of ["yarn", "pnpm", "bun"] as const) { + const content = renderRepoDocContent(presentProfile({ commands: { packageManager, buildCommands: ["build"], testCommands: [], lintCommands: [] } })); + expect(content).toContain(`Package manager: ${packageManager}`); + expect(content).toContain(`- Build: \`${packageManager} run build\``); + } + }); + + it("lists multiple commands for the same category as a comma-separated set", () => { + const content = renderRepoDocContent(presentProfile({ commands: { packageManager: "npm", buildCommands: [], testCommands: ["test", "test:watch"], lintCommands: [] } })); + expect(content).toContain("- Test: `npm run test`, `npm run test:watch`"); + }); + + it("renders 'none indexed' when no CI workflow files were found", () => { + const content = renderRepoDocContent(presentProfile({ contributionWorkflow: { gatePublishesCheck: false, linkedIssuePolicy: "optional", requireLinkedIssue: false, ciWorkflowFiles: [] } })); + expect(content).toContain("CI publishes a required check: no"); + expect(content).toContain("Requires a linked issue: no"); + expect(content).toContain("- none indexed"); + }); + + it("renders 'yes' for requireLinkedIssue when the setting is on", () => { + const content = renderRepoDocContent(presentProfile({ contributionWorkflow: { gatePublishesCheck: true, linkedIssuePolicy: "required", requireLinkedIssue: true, ciWorkflowFiles: [] } })); + expect(content).toContain("Requires a linked issue: yes"); + }); + + const namingStyles: Array<[RepoProfileFileNamingStyle, string]> = [ + ["kebab-case", "kebab-case (`my-file.ts`)"], + ["camelCase", "camelCase (`myFile.ts`)"], + ["snake_case", "snake_case (`my_file.ts`)"], + ["PascalCase", "PascalCase (`MyFile.ts`)"], + ["mixed", "mixed -- no single dominant style detected"], + ["unknown", "not detected"], + ]; + it.each(namingStyles)("renders the file-naming label for %s", (style, label) => { + const content = renderRepoDocContent(presentProfile({ conventions: { fileNamingStyle: style, testFileConvention: "none-detected" } })); + expect(content).toContain(`File naming: ${label}`); + }); + + const testConventions: Array<[RepoProfileTestFileConvention, string]> = [ + ["dot-test-suffix", "`*.test.*` files"], + ["dot-spec-suffix", "`*.spec.*` files"], + ["tests-directory", "a `tests/`/`__tests__/` directory"], + ["none-detected", "not detected"], + ]; + it.each(testConventions)("renders the test-file-convention label for %s", (convention, label) => { + const content = renderRepoDocContent(presentProfile({ conventions: { fileNamingStyle: "unknown", testFileConvention: convention } })); + expect(content).toContain(`Test files: ${label}`); + }); +});