diff --git a/src/github/repo-doc-pr.ts b/src/github/repo-doc-pr.ts index aec6855f62..bc9572c074 100644 --- a/src/github/repo-doc-pr.ts +++ b/src/github/repo-doc-pr.ts @@ -1,13 +1,21 @@ -// 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"; +// Repo-doc PR delivery (#3000/#3004, 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. +// +// DIFF-AWARE REFRESH (#3004): before building anything, the CURRENT AGENTS.md on the default branch (if any) is +// fetched and run through src/review/generated-doc-refresh.ts's marker-block refresh. That call is the single +// source of truth for what happens next -- a first-run repo gets the full generated content, a repo whose +// 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. +import { githubErrorStatus, 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 { REPO_DOC_MARKERS, renderRepoDocContent } from "../review/repo-doc-render"; +import { refreshGeneratedDoc } from "../review/generated-doc-refresh"; 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 @@ -33,6 +41,30 @@ function splitRepo(repoFullName: string): { owner: string; repo: string } { type DocTreeEntry = { path: string; mode: "100644" | "120000"; type: "blob"; content: string }; type Octokit = ReturnType; +// GitHub's Contents API base64-encodes the file's raw bytes (with line-wrapped whitespace); decoding through +// atob + TextDecoder (rather than a naive charCodeAt reassembly) is what makes this correct for non-ASCII +// manual content a maintainer added outside the generated markers. +function decodeGitHubFileContent(base64: string): string { + const binary = atob(base64.replace(/\s+/g, "")); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return new TextDecoder().decode(bytes); +} + +/** The current AGENTS.md content on `ref`, or `null` when it doesn't exist yet (first run). Any OTHER failure + * (rate limit, auth, a transient 5xx) is rethrown -- a repo we simply couldn't read must never be treated the + * same as a genuinely empty one, or a refresh could mistake "we don't know" for "there's nothing there yet". */ +async function fetchExistingAgentsMdContent(octokit: Octokit, owner: string, repo: string, ref: string): Promise { + try { + const response = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path: AGENTS_FILE_PATH, ref }); + const data = response.data as { content?: string }; + return typeof data.content === "string" ? decodeGitHubFileContent(data.content) : null; + } catch (error) { + if (githubErrorStatus(error) === 404) return null; + throw error; + } +} + /** 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. @@ -74,9 +106,11 @@ Disable repo-doc generation for this repository, or simply close this pull reque * 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. + * different action), the diff-aware refresh (#3004) found nothing meaningful to change, the existing file's + * marker block is missing/malformed (fails closed rather than guessing), 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 { @@ -85,8 +119,8 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod 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" }; + const generatedSection = renderRepoDocContent(profile); + if (!generatedSection) 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}"` }; @@ -101,6 +135,12 @@ export async function openRepoDocPullRequest(env: Env, repoFullName: string, mod 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 currentAgentsContent = await fetchExistingAgentsMdContent(octokit, owner, repo, baseBranch); + const refresh = refreshGeneratedDoc(currentAgentsContent, generatedSection, REPO_DOC_MARKERS); + 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 }); const baseCommitSha = branchInfo.data.commit.sha; const baseTreeSha = branchInfo.data.commit.commit.tree.sha; diff --git a/src/review/generated-doc-refresh.ts b/src/review/generated-doc-refresh.ts new file mode 100644 index 0000000000..5adcef5495 --- /dev/null +++ b/src/review/generated-doc-refresh.ts @@ -0,0 +1,72 @@ +// Generic marker-block refresh (#3004, part of the repo-doc generation roadmap #2993). A single, reusable +// mechanism for "recompute the machine-generated section of a file, leave everything outside it byte-for-byte +// untouched" -- used today by AGENTS.md/CLAUDE.md (src/github/repo-doc-pr.ts, src/review/repo-doc-render.ts) +// and meant to be reused UNCHANGED by any future generated skill file (#3001) and by the scheduled-refresh +// no-meaningful-change check (#3003), so those features never grow a second, divergent diff implementation. +// +// FAILS CLOSED ON A MISSING/ALTERED MARKER BLOCK: an existing file with no marker block at all, or a malformed +// one (missing start/end, duplicated, or out of order), is NEVER silently overwritten -- refresh returns +// `manual-review-required` instead of guessing which part of the file is safe to replace. This is what lets a +// maintainer's hand-written content survive: anything outside a valid marker block is preserved verbatim, and +// anything that no longer LOOKS like a valid marker block halts automation rather than clobbering it. + +export type GeneratedDocMarkers = { start: string; end: string }; + +export type GeneratedDocRefreshResult = + | { action: "generate"; content: string } + | { action: "replace"; content: string } + | { action: "no-change" } + | { action: "manual-review-required"; reason: string }; + +type MarkerBlock = { startIndex: number; endIndex: number }; + +function countOccurrences(haystack: string, needle: string): number { + let count = 0; + let index = haystack.indexOf(needle); + while (index !== -1) { + count += 1; + index = haystack.indexOf(needle, index + needle.length); + } + return count; +} + +function findMarkerBlock(content: string, markers: GeneratedDocMarkers): MarkerBlock | { error: string } { + const startCount = countOccurrences(content, markers.start); + const endCount = countOccurrences(content, markers.end); + if (startCount === 0 && endCount === 0) return { error: "no generated-content marker block found" }; + if (startCount !== 1) return { error: `expected exactly one start marker, found ${startCount}` }; + if (endCount !== 1) return { error: `expected exactly one end marker, found ${endCount}` }; + const startIndex = content.indexOf(markers.start); + // A renderer's own output (e.g. renderRepoDocContent) always ends with `${end marker}\n` -- one trailing + // newline is considered PART of the generated section, not "after" content. Consuming it here too keeps a + // freshly re-extracted `currentSection` byte-identical to a freshly rendered `generatedSection` when nothing + // actually changed; without it, `no-change` could never fire for any real renderer output. + let endIndex = content.indexOf(markers.end) + markers.end.length; + if (content[endIndex] === "\n") endIndex += 1; + if (endIndex <= startIndex + markers.start.length) return { error: "end marker appears before (or immediately at) the start marker" }; + return { startIndex, endIndex }; +} + +/** + * Recompute the machine-generated section of a file. `generatedSection` MUST already carry the `markers.start`/ + * `markers.end` pair as its own first/last content (e.g. `renderRepoDocContent`'s output) -- this function only + * finds and replaces that span inside a LARGER file, it does not add the markers itself. + * + * - `currentContent === null` (no file exists yet): `generate` -- content is exactly `generatedSection`. + * - A valid, single marker block is found and its current text already equals `generatedSection`: `no-change` + * -- callers (including the future scheduled-refresh check, #3003) use this to skip opening a no-op PR. + * - A valid, single marker block is found and differs: `replace` -- `content` preserves everything before the + * start marker and after the end marker byte-for-byte, substituting only the marked span. + * - No marker block, or a malformed one (missing start/end, duplicated, or end-before-start): `manual-review- + * required` -- an existing file that doesn't unambiguously look machine-generated is never touched. + */ +export function refreshGeneratedDoc(currentContent: string | null, generatedSection: string, markers: GeneratedDocMarkers): GeneratedDocRefreshResult { + if (currentContent === null) return { action: "generate", content: generatedSection }; + const block = findMarkerBlock(currentContent, markers); + if ("error" in block) return { action: "manual-review-required", reason: block.error }; + const currentSection = currentContent.slice(block.startIndex, block.endIndex); + if (currentSection === generatedSection) return { action: "no-change" }; + const before = currentContent.slice(0, block.startIndex); + const after = currentContent.slice(block.endIndex); + return { action: "replace", content: `${before}${generatedSection}${after}` }; +} diff --git a/src/review/repo-doc-render.ts b/src/review/repo-doc-render.ts index f47da56901..e47d368e26 100644 --- a/src/review/repo-doc-render.ts +++ b/src/review/repo-doc-render.ts @@ -1,20 +1,26 @@ -// 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. +// Repo-doc content rendering (#3000/#3004, 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, wrapped in a start/end marker +// pair (src/review/generated-doc-refresh.ts) so a refresh can recompute just this section and leave anything a +// maintainer added outside it untouched. Pure and deterministic: no GitHub calls, no AI -- and deliberately no +// embedded wall-clock timestamp, since that would make the SAME profile render different content on every call +// and defeat the refresh module's byte-for-byte "did anything actually change" comparison (#3004). 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"; +import type { GeneratedDocMarkers } from "./generated-doc-refresh"; /** 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; +export const REPO_DOC_TEMPLATE_VERSION = 2; -/** 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 = ``; +/** HTML-comment marker pair bracketing the machine-generated section of every AGENTS.md this engine writes. + * Content outside this pair (added by a maintainer before the start marker or after the end marker) is treated + * as permanently manual and is never touched by a refresh (#3004) -- see generated-doc-refresh.ts. */ +export const REPO_DOC_MARKER_START = ""; +export const REPO_DOC_MARKER_END = ""; +export const REPO_DOC_MARKERS: GeneratedDocMarkers = { start: REPO_DOC_MARKER_START, end: REPO_DOC_MARKER_END }; const MAX_RENDERED_TOP_LEVEL_DIRECTORIES = 12; @@ -56,17 +62,22 @@ function renderCiWorkflowFiles(ciWorkflowFiles: string[]): string { /** * 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. + * (`present: false`) -- callers must treat `null` as "do not generate", not as an empty-but-valid document. The + * ENTIRE return value is the machine-generated section: it both starts and ends with the marker pair + * (`REPO_DOC_MARKERS`), so on a first-run file this IS the whole document, and on a refresh + * (src/review/generated-doc-refresh.ts) it is exactly the span that gets recomputed -- anything a maintainer + * adds before the start marker or after the end marker in the delivered file is never part of this output and + * is therefore never touched. */ 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} + return `${REPO_DOC_MARKER_START} +# AGENTS.md 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. +code -- it is not hand-written and not a generic template. Content between the markers above and below this +line is recomputed on every refresh; add anything you want kept forever outside them instead. ## Architecture @@ -97,7 +108,7 @@ ${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. +Generated by Gittensory from this repository's own indexed code. +${REPO_DOC_MARKER_END} `; } diff --git a/test/unit/generated-doc-refresh.test.ts b/test/unit/generated-doc-refresh.test.ts new file mode 100644 index 0000000000..092e0a5dc8 --- /dev/null +++ b/test/unit/generated-doc-refresh.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { refreshGeneratedDoc } from "../../src/review/generated-doc-refresh"; +import { REPO_DOC_MARKERS, renderRepoDocContent } from "../../src/review/repo-doc-render"; +import { REPO_PROFILE_SCHEMA_VERSION } from "../../src/review/repo-profile"; +import type { RepoProfile } from "../../src/review/repo-profile"; + +const MARKERS = { start: "", end: "" }; +const SECTION = `${MARKERS.start}\ngenerated body v1\n${MARKERS.end}\n`; +const SECTION_V2 = `${MARKERS.start}\ngenerated body v2\n${MARKERS.end}\n`; + +function fixtureProfile(): RepoProfile { + return { + version: REPO_PROFILE_SCHEMA_VERSION, + present: true, + repoFullName: "owner/widgets", + generatedAt: "2026-07-04T00:00:00.000Z", + architecture: { indexedFileCount: 3, topLevelDirectories: [{ path: "src", fileCount: 3 }] }, + conventions: { fileNamingStyle: "kebab-case", testFileConvention: "dot-test-suffix" }, + commands: { packageManager: "npm", buildCommands: ["build"], testCommands: ["test"], lintCommands: [] }, + contributionWorkflow: { gatePublishesCheck: true, linkedIssuePolicy: "preferred", requireLinkedIssue: false, ciWorkflowFiles: [] }, + }; +} + +describe("refreshGeneratedDoc (#3004)", () => { + it("generates fresh content when there is no current file at all", () => { + expect(refreshGeneratedDoc(null, SECTION, MARKERS)).toEqual({ action: "generate", content: SECTION }); + }); + + it("reports no-change when the current marker block already matches the freshly rendered section exactly", () => { + const current = `# Preamble\n\n${SECTION}Appendix.\n`; + expect(refreshGeneratedDoc(current, SECTION, MARKERS)).toEqual({ action: "no-change" }); + }); + + it("replaces only the marked span, preserving manual content before and after it byte-for-byte", () => { + const current = `# Preamble\n\n${SECTION}Appendix.\n`; + const result = refreshGeneratedDoc(current, SECTION_V2, MARKERS); + expect(result).toEqual({ action: "replace", content: `# Preamble\n\n${SECTION_V2}Appendix.\n` }); + }); + + it("replaces with no leftover appendix when the marked block (plus its trailing newline) spans the entire file", () => { + const result = refreshGeneratedDoc(SECTION, SECTION_V2, MARKERS); + expect(result).toEqual({ action: "replace", content: SECTION_V2 }); + }); + + it("fails closed with a reason when the current file has no marker block at all", () => { + const current = "# Hand-written CLAUDE.md\n\nNo markers here.\n"; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "no generated-content marker block found" }); + }); + + it("fails closed when only the start marker is present", () => { + const current = `# Doc\n\n${MARKERS.start}\norphaned start\n`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "expected exactly one end marker, found 0" }); + }); + + it("fails closed when only the end marker is present", () => { + const current = `# Doc\n\norphaned end\n${MARKERS.end}\n`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "expected exactly one start marker, found 0" }); + }); + + it("fails closed when the start marker appears twice", () => { + const current = `${MARKERS.start}\n${MARKERS.start}\nbody\n${MARKERS.end}\n`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "expected exactly one start marker, found 2" }); + }); + + it("fails closed when the end marker appears twice", () => { + const current = `${MARKERS.start}\nbody\n${MARKERS.end}\n${MARKERS.end}\n`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "expected exactly one end marker, found 2" }); + }); + + it("fails closed when the end marker appears before the start marker", () => { + const current = `${MARKERS.end}\nbody\n${MARKERS.start}\n`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "manual-review-required", reason: "end marker appears before (or immediately at) the start marker" }); + }); + + it("treats adjacent markers with an empty body between them as a valid (if degenerate) block, not an error", () => { + const current = `${MARKERS.start}${MARKERS.end}`; + const result = refreshGeneratedDoc(current, SECTION, MARKERS); + expect(result).toEqual({ action: "replace", content: SECTION }); + }); + + it("REGRESSION: a real renderRepoDocContent() output round-trips as no-change against itself, with or without surrounding manual content", () => { + const rendered = renderRepoDocContent(fixtureProfile())!; + expect(refreshGeneratedDoc(rendered, rendered, REPO_DOC_MARKERS)).toEqual({ action: "no-change" }); + + const withManualContent = `\n\n${rendered}\n\n`; + expect(refreshGeneratedDoc(withManualContent, rendered, REPO_DOC_MARKERS)).toEqual({ action: "no-change" }); + }); +}); diff --git a/test/unit/repo-doc-pr.test.ts b/test/unit/repo-doc-pr.test.ts index 2d549a6f6e..121633e0bc 100644 --- a/test/unit/repo-doc-pr.test.ts +++ b/test/unit/repo-doc-pr.test.ts @@ -4,8 +4,14 @@ 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 { renderRepoDocContent } from "../../src/review/repo-doc-render"; +import { extractRepoProfile } from "../../src/review/repo-profile"; import { createTestEnv } from "../helpers/d1"; +function base64Utf8(text: string): string { + return Buffer.from(text, "utf8").toString("base64"); +} + const REPO = "owner/widgets"; const [PROJECT, CHUNK_REPO] = ["owner", "widgets"]; @@ -111,6 +117,7 @@ describe("openRepoDocPullRequest (#3000)", () => { 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 new Response("not found", { status: 404 }); 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" }); @@ -150,6 +157,7 @@ describe("openRepoDocPullRequest (#3000)", () => { 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.includes("/contents/AGENTS.md") && method === "GET") return new Response("not found", { status: 404 }); 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; @@ -178,6 +186,7 @@ describe("openRepoDocPullRequest (#3000)", () => { 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.includes("/contents/AGENTS.md") && method === "GET") return new Response("not found", { status: 404 }); 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" }); @@ -190,6 +199,117 @@ describe("openRepoDocPullRequest (#3000)", () => { expect(calls.some((c) => c === "GET https://api.github.com/repos/owner/widgets")).toBe(true); }); + it("#3004: treats a contents response with no string .content as first-run (generates fresh content)", 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.includes("/contents/AGENTS.md") && method === "GET") return Response.json([{ name: "AGENTS.md", type: "dir" }]); + 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({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 61, html_url: "https://github.com/owner/widgets/pull/61" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 61, url: "https://github.com/owner/widgets/pull/61", claudeMode: "symlink" }); + }); + + it("REGRESSION (#3004): reports no-change and creates no branch/commit/PR when the existing AGENTS.md is already current", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + const profile = await extractRepoProfile(env, REPO); + const currentContent = renderRepoDocContent(profile)!; + let wroteAnything = false; + 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.includes("/contents/AGENTS.md") && method === "GET") return Response.json({ content: base64Utf8(currentContent), encoding: "base64" }); + if (method === "POST") wroteAnything = true; + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "no meaningful change since the last generated AGENTS.md" }); + expect(wroteAnything).toBe(false); + }); + + it("#3004: rethrows (rather than treating as first-run) a non-404 failure while fetching the existing AGENTS.md", 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.includes("/contents/AGENTS.md") && method === "GET") return Response.json({ message: "rate limited" }, { status: 403 }); + 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(/rate limited/); + }); + + it("#3004: fails closed with a manual-review reason and creates no branch/commit/PR when the existing file has no marker block", async () => { + const env = envWithKey(); + await seedInstalledRepo(env, { defaultBranch: "main" }); + await seedProfileData(env); + let wroteAnything = false; + 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.includes("/contents/AGENTS.md") && method === "GET") return Response.json({ content: base64Utf8("# Hand-written AGENTS.md\n\nNo markers here.\n"), encoding: "base64" }); + if (method === "POST") wroteAnything = true; + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: false, reason: "AGENTS.md needs manual review before it can be refreshed: no generated-content marker block found" }); + expect(wroteAnything).toBe(false); + }); + + 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); + 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`; + 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(currentContent), 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: "refresh-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "refresh-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: 55, html_url: "https://github.com/owner/widgets/pull/55" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await openRepoDocPullRequest(env, REPO, "live"); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 55, url: "https://github.com/owner/widgets/pull/55", 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).toContain("# Preamble the maintainer added."); + expect(agentsEntry?.content).toContain("An appendix the maintainer added."); + expect(agentsEntry?.content).toContain("Lint: `npm run lint`"); + expect(agentsEntry?.content).not.toContain("an older lint command"); + }); + 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" }); @@ -199,6 +319,7 @@ describe("openRepoDocPullRequest (#3000)", () => { 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.includes("/contents/AGENTS.md") && method === "GET") return new Response("not found", { status: 404 }); 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 }); diff --git a/test/unit/repo-doc-render.test.ts b/test/unit/repo-doc-render.test.ts index a497f8a246..42b016b723 100644 --- a/test/unit/repo-doc-render.test.ts +++ b/test/unit/repo-doc-render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { REPO_DOC_CONTENT_MARKER, renderRepoDocContent } from "../../src/review/repo-doc-render"; +import { REPO_DOC_MARKER_END, REPO_DOC_MARKER_START, 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"; @@ -26,7 +26,8 @@ describe("renderRepoDocContent (#3000)", () => { 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!.startsWith(REPO_DOC_MARKER_START)).toBe(true); + expect(content!.trimEnd().endsWith(REPO_DOC_MARKER_END)).toBe(true); expect(content).toContain("# AGENTS.md"); expect(content).toContain("42 indexed source files across 2 top-level directories"); expect(content).toContain("- `src` -- 30 files"); @@ -41,7 +42,13 @@ describe("renderRepoDocContent (#3000)", () => { 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."); + expect(content).toContain("Generated by Gittensory from this repository's own indexed code."); + }); + + it("renders byte-identical output for the same profile facts regardless of generatedAt, so refresh's no-change check is meaningful", () => { + const a = renderRepoDocContent(presentProfile({ generatedAt: "2026-01-01T00:00:00.000Z" })); + const b = renderRepoDocContent(presentProfile({ generatedAt: "2026-12-31T23:59:59.000Z" })); + expect(a).toEqual(b); }); it("uses singular wording for exactly one indexed file and one top-level directory", () => {