Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions src/github/repo-doc-pr.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof makeInstallationOctokit>;

// 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<string | null> {
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.
Expand Down Expand Up @@ -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<RepoDocPullRequestResult> {
try {
Expand All @@ -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}"` };

Expand All @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions src/review/generated-doc-refresh.ts
Original file line number Diff line number Diff line change
@@ -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}` };
}
43 changes: 27 additions & 16 deletions src/review/repo-doc-render.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!-- gittensory-repo-doc:v${REPO_DOC_TEMPLATE_VERSION} -->`;
/** 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 = "<!-- gittensory-repo-doc:start -->";
export const REPO_DOC_MARKER_END = "<!-- gittensory-repo-doc:end -->";
export const REPO_DOC_MARKERS: GeneratedDocMarkers = { start: REPO_DOC_MARKER_START, end: REPO_DOC_MARKER_END };

const MAX_RENDERED_TOP_LEVEL_DIRECTORIES = 12;

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}
`;
}
Loading
Loading