Improve forgejo_get_pr_diff for large PRs #9
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#9
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
When reviewing PR #8 (33 files, +2448 −43), a single
forgejo_get_pr_diffcall returned an incomplete diff:apps/server/src/routes-forgejo-webhook.test.ts, so the route files and the wholeapps/webpart were never fetched.pathsbecause their names were unknown.To get a complete diff, the agent would need several more calls with
pathsbatches it had to guess.Current state
pathsbatches.Implementation plan
Context
through## Assumptions & contingencies. Writes it withforgejo_update_issue_description`.deck_inbox_capturekindidea, sourcechat, titleSnapshot pattern for Forgejo comment/review lists, body: "Follow-up to #9 item 6. Apply the snapshot pattern toforgejo_list_pr_comments_and_reviewsandforgejo_list_issue_comments: fetch all pages, write the full timeline tolocal://, return counts plus the path inline. Comments have no natural SHA pin, so each call is a fresh snapshot."Context
forgejo_get_pr_diffreturns the diff inline. Output stops at about 100 KB (even partway through a file), and the file list stops at 30 entries, so large PRs can't be reviewed. Target end state:local://) as one numbered file per changed file, pinned to the head SHA and reused on repeat calls.detailspayload for a web card.since_shalimits the snapshot to changes after that commit.forgejo_read_file_at_refgets the same snapshot treatment.forgejo_read_prstops listing a truncated file list and points to the diff tool instead.Approach
1. Give Forgejo tools the session's
local://rootapps/server/src/forgejo/tools/shared.ts:export interface ForgejoCallContext { localProtocolOptions?: LocalProtocolOptions }, withimport type { LocalProtocolOptions } from "@oh-my-pi/pi-coding-agent/internal-urls". This path is the package export already used bybridge/plan-mode-bridge.tsL48.ForgejoToolSpec.runtorun(ctx, params, signal?: AbortSignal, call?: ForgejoCallContext).defineForgejoTool'sexecute(_toolCallId, params, _onUpdate, sdkCtx, signal), pass{ localProtocolOptions: sdkCtx?.localProtocolOptions }. The SDK fills this per call (createCustomToolContext,node_modules/@oh-my-pi/pi-coding-agent/src/sdk.tsL1337-1346). Tools are built before the session exists (buildForgejoTools), so the context must come per call, not throughForgejoToolContext.mapLimitfromprs.ts(L66-78) intoshared.tsas an export, unchanged. Its two callers inprs.ts(fetchInlineComments,searchPrs) import it from there.apps/server/src/forgejo/tools/snapshot.ts. Nothing equivalent exists.export const SNAPSHOT_READ_BYTES = 64 * 1024 * 1024;export function localRoot(call?: ForgejoCallContext): { ok: true; root: string } | { ok: false; error: string }. UsesresolveLocalRoot(options)from@oh-my-pi/pi-coding-agent/internal-urls, which gives<artifactsDir>/localor a tmp fallback. WithoutlocalProtocolOptionsit returns the errorsession storage (local://) is not available in this session.export function safeRelPath(p: string): string | null. Returnsnullfor an absolute path or any/-separated segment equal to"","."or"..". PR content can come from forks, so these checks must happen before any write.export async function writeSnapshotDir(root: string, name: string, files: { rel: string; content: string }[]): Promise<"written" | "exists">:mkdir(root, { recursive: true }), thentmp = mkdtemp(join(root, ".forgejo-tmp-")).tmp, creating parent dirs. Assert each resolved path stays insidetmp.rename(tmp, join(root, name)). OnEEXIST/ENOTEMPTY, removetmpand return"exists".tmpand rethrow.export async function writeFileAtomic(root: string, rel: string, content: string): Promise<void>. Writes tojoin(root, ".forgejo-tmp-<random>"), creates the target's parent dir, then renames the temp file over the target.export function formatSize(bytes: number): string. 1024-based:512 B,4.1 KB,12.3 MB(one decimal for KB/MB).2. Per-file diff analysis and numbering (pure)
In
apps/server/src/forgejo/tools/diff.ts:filterDiff,normalizeFilterandFilteredDiff;getPrDiffwas their only caller. Deletedescribe("filterDiff")indiff.test.ts.parseDiffHeader: the quoted branch returns paths decoded by a newunquoteGitPath(s: string): string.\\ \" \a \b \t \n \v \f \r, and\NNNoctal turned into bytes.TextDecoder.diff.test.tsL17 to expectcafé.md.export type DiffStatus = "A" | "M" | "D" | "R" | "C"and the analysis function:@@:new file mode→ A;deleted file mode→ D;rename from→ R;copy from→ C; otherwise M.Binary filesor equalsGIT binary patch.^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(missing counts default to 1). TrackoldLeft/newLeftfrom the header, and leave the hunk when both reach 0. Counting, not prefix-sniffing, ends hunks, so content lines starting with---/+++are counted correctly.' 'or an empty line: context; both numbers advance.'-': deletion; counts towarddeletionsand advances the old number.'+': addition; counts towardadditionsand advances the new number.\(e.g.\ No newline at end of file) gets a blank gutter and consumes nothing. This applies even right after a hunk ended.${old.padStart(w)} ${new.padStart(w)} |${line}. A missing number is"",linekeeps its marker character, andwis the digit count of the largest line number in the chunk (at least 1).diff --git,index, mode, rename,---/+++,@@,Binary files …) stay verbatim and unnumbered.3. Snapshot manifest, index text and card details
packages/protocol/src/forgejo-tools.ts, add:apps/server/src/forgejo/tools/pr-diff.ts, which holds the diff tool's logic.INLINE_SUMMARY_BYTES = 32 * 1024,CARD_FILE_LIMIT = 30.pr-<n>@<head7>.pr-<n>@<head7>-since-<since7>. Don't use..: the SDK'svalidateRelativePathrejects..segments and/...<name>/, one<repo path>.diffper stored file.<name>.md, as a sibling so it can never collide with a repo path.<name>.json, written last. Its presence marks the snapshot complete.renderIndex(m: PrDiffManifest): string. The header block comes first, ended by a blank line:PR #<n> @ <headSha> · merge base <mergeBase7 or ?> · <F> files +<A> −<D>.PR #<n> changes since <sinceSha> → <headSha> · <F> files +<A> −<D>.snapshot: <snapshot>/<path>.diff · index: <index>.gutter: "<old> <new> |<diff line>"; comment with forgejo_post_review line=<new> (side new), or line=<old> with side "old" for removed lines.TRUNCATED: the diff exceeded the 64 MB read limit. [missing] files have no snapshot; [truncated] files are cut off.missingrows last:<status> +<add> −<del> <formatSize(bytes)> <oldPath → path | path>plus[<flag>]when the flag is set.(no changes).renderInline(index: string, m): string. If the index is at mostINLINE_SUMMARY_BYTESin UTF-8, return it whole. Otherwise return the header block plus\n\n<F> files; full list in <index URL>.toDetails(m, reused: boolean): ForgejoPrDiffResult. Computestotals, and setsfilestonullwhen there are more thanCARD_FILE_LIMITfiles.pr-diff.ts:added → A,deleted → D,renamed → R,copied → C, anything else → M. It is used only formissingrows.async function storeSnapshot(root, name, m, stored: { rel: string; content: string }[]): Promise<{ manifest: PrDiffManifest; reused: boolean } | { error: string }>:writeSnapshotDir."written":writeFileAtomic(root, name + ".md", renderIndex(m)), thenwriteFileAtomic(root, name + ".json", JSON.stringify(m, null, 2))."exists": read the manifest. If present, reuse it. If absent, return the erroranother call is writing snapshot <name>; call again.async function loadManifest(root, name): Promise<PrDiffManifest | null>. Returnsnullwhen the file is missing or its JSON doesn't parse.headShaandsinceShaequal the requested full SHAs, returntextResult(renderInline(<read index file>, m), toDetails(m, true)).snapshot <name> belongs to a different commit (<m.headSha>); short-SHA collision.<name>exists without a manifest (an incomplete earlier write),rm -rfthe dir and continue.4. Full-mode
forgejo_get_pr_diffapi-types.ts: addmerge_base?: string | nulltoApiPull, and addexport interface ApiCommit { sha: string }.prs.tsgetPrDiff(L230-282):{ number: numberField("Pull request"), since_sha: z.string().regex(/^[0-9a-f]{7,40}$/i).optional().describe("Only changes after this commit (7-40 hex chars), e.g. the head you last reviewed") }.pathsis removed.Forgejo: snapshot the diff of a pull request in ${REPO_SCOPE} into session storage and return a summary listing every changed file. Each file's diff is stored at local://pr-<n>@<head7>/<path>.diff (read with `read`, search with `grep`) with old and new line numbers in a gutter: use the new number as `line` in forgejo_post_review, or the old number with side "old" for removed lines. Snapshots are pinned to the head commit and reused. Pass since_sha to get only what changed after that commit.runcallsbuildPrDiff(ctx, p.number, p.since_sha, signal, call), exported frompr-diff.ts.pr-diff.ts:localRoot(call); on error,errorResult.GET pulls/{n}, which givesheadSha = head.shaandmergeBaseSha = merge_base ?? null. Missing head SHA → errorPR #<n> has no head commit. Then run the reuse check.ctx.client.text(repoApiPath(owner, repo, "pulls",${n}.diff), { maxBytes: SNAPSHOT_READ_BYTES, signal }).GET pulls/{n}again. If the head changed, return the errorPR #<n> head moved from <a7> to <b7> while the diff was being fetched; call again, and write nothing.splitDiff. For each file:safeRelPath(path)returns null → errorrefusing to store a diff for unsafe path "<path>".analyzeDiffChunk.flag="binary"if binary, else null.oldPath=oldPathwhen it differs frompath, else null.{ rel: path + ".diff", content: numbered }, andbytes= its UTF-8 length.res.truncated:truncated, and give the last diff file the flag"truncated".fetchAllPages<ApiChangedFile>(ctx.client, pullPath(ctx, n, "files"), { maxPages: 100, signal }). On failure, returnapiErrorand write nothing.filenameis not among the diff paths becomes a row: mapped status,additions/deletionsfrom the API,bytes: 0,flag: "missing",oldPath: previous_filenameif it differs. No file is stored for these rows.storeSnapshot, then returntextResult(renderInline(index, m), toDetails(m, reused)).5. Since mode (
since_sha), API-onlyThe Forgejo API has no range-diff endpoint (verified on Forgejo 16.0.6). The changed files are found through compare, raw content is fetched at both SHAs, and
git diff --no-indexdiffs them locally.GET pulls/{n}for the head SHA.since_shawithGET repoApiPath(owner, repo, "commits"), query{ sha: since, limit: 1, stat: false, verification: false, files: false }, which returnsApiCommit[].since_sha <since> not found in <owner>/<repo>.[0].shaas the full SHA.pr-<n>@<head7>-since-<since7>.Promise.allofGET compare/<since>...<head>andGET compare/<head>...<since>. A new typeApiCompare { total_commits?: number | null; commits?: unknown[] | null; files?: { filename: string; status?: string | null }[] | null }goes inapi-types.ts.apiError.total_commitsis greater thancommits.length→ errorcompare <a7>...<b7> lists only <x> of <t> commits; the range is too large for since_sha.files[].filenamefrom both responses, sorted alphabetically. Every path must passsafeRelPath. Querying both directions covers every path that differs, since each one is touched on one side of the merge base.mkdtemp(join(os.tmpdir(), "omp-deck-since-"))containinga/andb/. Delete it infinally(rm, recursive and force).mapLimit(paths, 4, …), fetch each path twice:ctx.client.text(repoApiPath(owner, repo, "raw", encodePath(path)), { query: { ref: <full sha> }, maxBytes: SNAPSHOT_READ_BYTES, signal }), once at the since SHA and once at the head SHA.apiError(abort).+0 −0;bytes: 0;flag: "too-large".a/<path>and/orb/<path>. Content is decoded as UTF-8 byclient.text, which is lossy for non-UTF-8 files. Accepted.export async function diffTrees(workDir: string, signal?: AbortSignal): Promise<{ ok: true; text: string; truncated: boolean } | { ok: false; error: string }>:runGit(workDir, ["diff", "--no-index", "--no-prefix", "--no-color", "--no-ext-diff", "-M", "a", "b"], { readOnly: true, maxStdoutBytes: SNAPSHOT_READ_BYTES, timeoutMs: NETWORK_TIMEOUT_MS, signal }), all fromapps/server/src/bridge/git-tools/run.ts.forgejo/repo.tsL13 already importsrunGitfrom there.git diff failed: <stderr trimmed>.--no-prefixwith dirs nameda/byieldsdiff --git a/<path> b/<path>headers, the same shape as the PR diff.splitDiffandanalyzeDiffChunkas in full mode. Identical files produce no chunk and so drop out. If stdout was truncated, set the manifest'struncatedand give the last chunk the flag"truncated".storeSnapshotand return, as in full mode.6.
forgejo_read_prdrops its partial file listprs.tsreadPr: remove the/filesrequest and callformatPr(pr.data).format.ts:formatPr(pr: ApiPull): delete thePrFormatExtrasparameter and the files block (L120-124). Right after thechanges:line, always pushfiles: use forgejo_get_pr_diff for the full list and per-file diffs.PrFormatExtras,formatChangedFileandFILE_STATUS.prs.ts: deleteFILE_LIST_LIMITandFILTERABLE_READ_BYTES, plus the unused imports (formatChangedFile,filterDiff,truncationNoticeif unused).format.test.ts:filesargument and the two file-row expectations (L133-134).formatChangedFile status letterstest.7.
forgejo_read_file_at_refas a snapshot{ path, ref? }. RemovestartLine/endLine.Forgejo: snapshot a file from ${REPO_SCOPE} at a branch, tag, or commit (not your working tree) into session storage at local://tree@<sha7>/<path> and return its size and line count; read it with `read`. `ref` defaults to the repo's default branch.run:localRoot(call).GET commitswith{ sha: ref, limit: 1, stat: false, verification: false, files: false }; omitshawhen there's no ref, which gives the default branch. 404 with a ref → errorref "<ref>" not found; other failures →apiError; empty array → errorref "<ref>" has no commits.maxBytes: SNAPSHOT_READ_BYTES. A NUL byte → keep the existing binary error and write nothing.writeFileAtomic(root, "tree@<sha7>/<path>", text). Content at a SHA is immutable, so overwriting is safe, and the truncation state needs no storage.lines= 0 for an empty file; otherwise the count of\nplus 1 if the text doesn't end with\n.[truncated: the file exceeds the 64 MB read limit; the snapshot holds the first <formatSize(bytes)>].ForgejoReadFileResultasdetails.sliceNumberedLinesfromformat.tsand its tests (format.test.tsL268 onward).8. Web cards
apps/web/src/components/tools/git/ui.tsx: export the existingLETTER_TONE.apps/web/src/components/tools/forgejo/PrDiffView.tsx(ForgejoViewProps), followingPostReviewView.tsx.ForgejoShell, which renders the error text, the details, or linkified text for old results.ArgRow k="pull request"withPrRef, plusArgRow k="since"with aShaChip(n=7) whenargs.since_shais a string.isPrDiff(details)passes (a type guard in the file):ShaChipfor the head (n=7),sinceShaChipif present,<N> files,Counts,Tag"reused" (muted) if reused,Tag"truncated" (tone warn) if truncated.PathChipwithsnapshot + "/", plus aCopyButton(@/lib/CopyButton) withtext={snapshot}.files === null: aMutednote<N> files: full list in <index>plus aCopyButtonfor the index.Muted"no changes".<details open>with summaryfiles (N)(SUMMARY_CLASS).LazyDetailswith summaryfiles (N), collapsed.inline-block w-3 shrink-0 text-center font-mono text-2xs font-semibold+LETTER_TONE[status].title;old → newfor renames.Counts, thenformatBytes(bytes)from@/lib/utilsin muted text.Tag: warn fortruncated/missing/too-large, muted forbinary.forgejo/ReadFileView.tsx:ArgRow k="file"withPathChippath plus@ ref(the current read-file rendering, L150-158).ShaChip(n=7),<lines> lines,formatBytes(bytes), warnTag"truncated" when truncated, and the snapshotPathChipwith aCopyButton.ForgejoTool.tsx:forgejo_get_pr_diff→<PrDiffView {...view} />andforgejo_read_file_at_ref→<ReadFileView {...view} />in the dispatch block next to the write tools, and delete their old cases (L129-140, L143-167).forgejo_get_pr_diffreturns[prRef(num), since ? "since " + since.slice(0, 7) : ""]joined with·.details.apps/web/src/preview-forgejo.tsexportingFORGEJO_FIXTURES: Record<string, { name: string; args: Record<string, unknown>; text?: string; details?: unknown; isError?: boolean }>, followingpreview-git.ts.files: null, note); truncated with onemissingand onetruncatedrow; since mode with atoo-largerow; reused; error; read-file; read-file truncated.Preview.tsx: buildforgejoToolCallslikegitToolCalls(L205-217), withresult: { content: [{ type: "text", text }], details }. Add<Section title="Forgejo tools">after "Git tools".9. Permanent tests
diff.test.ts, foranalyzeDiffChunk:\ No newline at end of fileline with a blank gutter.--and an added line starting with++are counted, not treated as headers.parseDiffHeaderunquotes an octal UTF-8 path.snapshot.test.ts, using a temp root:safeRelPathrejects../x,a//b,a/./band/abs, and acceptsa/b c.ts.writeSnapshotDirwrites nested files and returns"exists"when the target exists, leaving no.forgejo-tmp-*entries behind.writeFileAtomicoverwrites an existing file.pr-diff.test.ts:renderInlinereturns the whole index under 32 KB, and above it the header block plus<N> files; full list in local://…md.toDetailsgivesfiles: nullfor 31 files and an array for 30.diffTreeson tempa//b/trees: modified, added and deleted files appear with A/D/M headers; an identical file is absent; an emptya/dir works.Critical files & anchors
apps/server/src/forgejo/tools/shared.tsdefineForgejoTool(L69-95): where the SDKctx.localProtocolOptionsis dropped today.apps/server/src/forgejo/tools/prs.tsgetPrDiff(L230-282),readFileAtRef(L284-323),readPr(L204-228): the tools being rewritten.apps/server/src/forgejo/tools/diff.tssplitDiff/parseDiffHeader: reused for per-file split.node_modules/@oh-my-pi/pi-coding-agent/src/internal-urls/local-protocol.tsresolveLocalRoot(L240) andresolveLocalUrlToPath(L296): howlocal://maps to disk. Treat it as read-only reference.apps/web/src/components/tools/forgejo/PostReviewView.tsx: the pattern for a details card with a type guard andForgejoShell.Verification
Use only the repo, the test suite, and the deck's own Forgejo tools in a deck session. Do not open the deck DB, read tokens or deck config, or call Forgejo outside the
forgejo_*tools.bun test src/forgejoinapps/server. The new tests from step 9 pass, and the removed tests are gone.bun run typecheckat the repo root.bun run dev:webat the repo root, openhttp://127.0.0.1:5173/preview?preview=1, and check the "Forgejo tools" section:Use
browserfor a screenshot, then close the tab.forgejo_get_pr_diff {number: 8}. Expect a header startingPR #8 @ fafd5d9fca59d86c241997ddb0604a36efe9bd8f, 35 files, and every row present. The PR API reports +2749 −47; if the summed rows differ, find out why before finishing. The card shows the "files: full list in …" note.read local://pr-8@fafd5d9.md, thenreadone.difffile from it.+line with new number N. Callforgejo_read_file_at_ref {path: <that file>, ref: "fafd5d9fca59d86c241997ddb0604a36efe9bd8f"}, thenread local://tree@fafd5d9/<path>at line N. The text matches.forgejo_get_pr_diff {number: 8, since_sha: "b6a37ee"}. Expect 15 files (the commits afterb6a37eetouch 15 paths) and a collapsed list in the card.forgejo_get_pr_diff {number: 8, since_sha: "zzz"}. The schema rejects it.forgejo_read_file_at_ref {path: "CHANGELOG.md", ref: "no-such-branch"}returnserror: ref "no-such-branch" not found.Assumptions & contingencies
readtool (bridge/session-policy.ts). There they get only the summary. This is accepted; their tool set is handled separately.git -M.git diff --no-indexfails whena/orb/is empty, rundiffTreesonce per path instead:git diff --no-index --no-prefix --no-color --no-ext-diff a/<path> b/<path>, passing/dev/nullfor the absent side. Rewrite each chunk'sdiff --git,---and+++lines toa/<path>/b/<path>(/dev/nullstays as is), concatenate the chunks, and re-run thediffTreestest.total_commits, skip that guard."exists".omp-deck referenced this issue2026-10-20 13:08:51 +02:00