From e70084976bb7d99344bf133a38d1dc6019913c41 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 04:39:16 +0200 Subject: [PATCH] fix(coding-agent): corrected issue-pr diff URL parsing to list outputs - Fixed `issue://owner/diff` and `pr://owner/diff` parsing so they resolve to issue and PR list outputs. - Fixed `pr` short-form parsing by requiring `scheme==='pr'` and a numeric host before `diff` matching. - Fixed PR unified-diff parsing to decode quoted header paths and count `----`/`++++` hunk lines as one deletion/addition. - Fixed `read` error rendering to emit status blocks with cleaned, range-aware, tab-normalized lines. - Stopped `github-cache` from chmod-ing existing parent directories, preserving pre-existing permission modes. --- packages/coding-agent/CHANGELOG.md | 8 +- .../src/internal-urls/issue-pr-protocol.ts | 6 +- packages/coding-agent/src/tools/fetch.ts | 22 ++- packages/coding-agent/src/tools/gh.ts | 140 ++++++++++++++++-- .../coding-agent/src/tools/github-cache.ts | 1 - packages/coding-agent/src/tools/read.ts | 33 ++++- .../internal-urls/issue-pr-protocol.test.ts | 15 ++ packages/coding-agent/test/tools/gh.test.ts | 53 ++++++- .../test/tools/github-cache.test.ts | 14 ++ 9 files changed, 265 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0075f6cc..a4653f6c6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Removed `op: issue_view` and `op: pr_view` from the `github` tool. Read single issues/PRs through `read issue://` / `read pr://` (or the long form `read issue:////` / `read pr:////`); append `?comments=0` to drop the comments section. The `issue` and `comments` parameters were removed from the tool schema since no remaining op consumes them. Mutating ops (`pr_create`, `pr_checkout`, `pr_push`), `repo_view`, `search_*`, and `run_watch` are unchanged. @@ -9,7 +10,7 @@ ### Added - Added markdown rendering for `read` results when content type is `text/markdown`, so GitHub internal-URL outputs are shown as formatted markdown instead of plain code blocks -- Added `pr:///diff`, `pr:///diff/`, and `pr:///diff/all` internal-URL shapes covering changed-file listings, per-file slices, and the full unified diff. They share one `pr-diff` SQLite cache row with the same TTL knobs as `pr://` views (`github.cache.softTtlSec` / `github.cache.hardTtlSec` / `github.cache.enabled`). Single PR views now advertise the diff entry point via a `Diff: pr://///diff` note. Cache schema bumped to `user_version = 2`; older rows are dropped on first open to relax the `kind` CHECK constraint. +- Added `pr:///diff`, `pr:///diff/`, and `pr:///diff/all` internal-URL shapes covering changed-file listings, per-file slices, and the full unified diff. They share one `pr-diff` SQLite cache row with the same TTL knobs as `pr://` views (`github.cache.softTtlSec` / `github.cache.hardTtlSec` / `github.cache.enabled`). Single PR views now advertise the diff entry point via a `Diff: pr://///diff` note. Cache schema bumped to `user_version = 3`; older rows are dropped on first open to add credential-scoped keys and relax the `kind` CHECK constraint. - Added `issue://` / `pr://` internal-URL schemes that share a SQLite-backed cache with the rest of the `github` tool. Single-item reads (`issue://`, `issue:////`) return rendered markdown and within `github.cache.softTtlSec` (default 5 minutes) skip the `gh` round-trip entirely; within `github.cache.hardTtlSec` (default 7 days) the cached row is returned and a background refresh is scheduled. Root and repo-scoped reads (`issue://`, `pr://owner/repo`) issue a live `gh issue list` / `gh pr list` for browsing, supporting `?state=open|closed|all` for issues, `?state=open|closed|merged|all` for PRs, and `?limit=`, `?author=`, `?label=` query params. Rendered output lands in `~/.omp/cache/github-cache.db` (override via `OMP_GITHUB_CACHE_DB`); disable the cache entirely with `github.cache.enabled = false`. Cwd→default-repo lookups (`gh repo view`) are memoized per-process. - Added new `Approve and compact context` choice to the ExitPlanMode approval selector. Sits between `Approve and execute` (purge session) and `Approve and keep context` (full transcript) — runs `/compact` on the plan-mode transcript with a planning-specific summarization hint, then dispatches the plan-approved execution turn so it lands on a fresh cache anchor with the summarized rationale carried over. Cancelling the compaction (Esc or any other abort source) defers the execution dispatch and surfaces a warning so the operator can resubmit manually; non-abort failures proceed best-effort. - Added `CompactionCancelledError` typed sentinel and `CompactionOutcome` (`"ok" | "cancelled" | "failed"`) return type to `@oh-my-pi/pi-coding-agent/session/compaction`. `CommandController.executeCompaction` and `handleCompactCommand` now return the outcome instead of `void` so callers can discriminate user-driven aborts from generic failures without inspecting error messages. @@ -22,15 +23,18 @@ ### Fixed +- Fixed `pr:////diff` URLs for repositories named `diff` to continue resolving to PR list lookups instead of being parsed as short-form diff links +- Fixed PR unified diff parsing so changed-file headers with quoted paths (such as paths containing spaces) are now detected correctly and hunk content lines beginning with `---`/`+++` are counted in additions/deletions - Fixed GitHub view caching to account for active credential identity and avoid serving cached issue/PR data across different account/token contexts - Fixed `read` call tracking so calls without an explicit path or URL target no longer appear as regular file reads in the execution tracker - Fixed `createAgentSession()` subscribing the `credential_disabled` bridge to a freshly discovered `AuthStorage` orphan when an embedder supplied only `options.modelRegistry` (no `options.authStorage`). Refresh failures emitted by `modelRegistry.getApiKey()` flow through `modelRegistry.authStorage`, so a divergent local instance silently swallowed every disable event and also leaked into the `mcpManager` and session result. The SDK now reconciles `authStorage` to `modelRegistry.authStorage` up front and rejects mismatched `options.authStorage`/`options.modelRegistry.authStorage` pairs at session construction. - Fixed `runSubagent` (subagent task executor) carrying the same latent `AuthStorage`/`ModelRegistry` divergence as `createAgentSession()`: when only `options.modelRegistry` was supplied, the executor previously fell through to a fresh `discoverAuthStorage()` and handed that orphan into `createAgentSession()` alongside a registry whose `.authStorage` was a different instance. The executor now reconciles to `modelRegistry.authStorage` before any further work and rejects mismatched `options.authStorage`/`options.modelRegistry.authStorage` pairs the same way the SDK does, so subagents can no longer silently observe a different storage view than their parent. - Fixed `github` tool's `search_issues`/`search_prs`/`search_code`/`search_commits`/`search_repos` ops always returning 0 results when the query contained more than one qualifier (e.g. `is:merged is:pr`, `is:open author:foo`). `gh search …` since the `advanced_search=true` rollout in gh 2.92 silently wraps multi-token positional queries in parentheses and quotes everything after the first qualifier as that qualifier's value (`is:"merged is:pr"`), which GitHub then matches as a literal state filter that no PR can satisfy. The tool now calls `gh api -X GET /search/ -f q=… -F per_page=…` directly so the qualifiers reach GitHub's search API verbatim. `is:issue`/`is:pr` and `repo:/` are appended internally to preserve the previous CLI-flag behavior; the user-facing query string in the formatted output is unchanged. `state` for merged PRs is derived from `pull_request.merged_at` so the rendered `State:` line stays `merged`/`closed`/`open` as before. +- Fixed `read` tool renderer rendering failed reads with a success check (`✓`) and styling the error message as file content while the surrounding box was red. The renderer now branches on `isError` for both file and URL paths: header shows `✘ Read ` with a proper error icon and the underlying message is rendered as an error line. `renderReadUrlResult` got the same treatment so failed URL reads also get the cross icon instead of falling through to the `"No response data"` Text fallback. Mirrors the `bash`/`find` renderer error pattern. ### Security -- Secured the GitHub cache store with strict file permissions (`0700` directories and `0600` files) to reduce local cache exposure +- Secured the GitHub cache store with strict file permissions (`0600` files) and private permissions for newly created cache directories (`0700`) to reduce local cache exposure ## [14.9.9] - 2026-05-12 diff --git a/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts b/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts index 1d3fa9543..38c61ada2 100644 --- a/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts +++ b/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts @@ -115,8 +115,10 @@ function parseUrl(url: InternalUrl, scheme: Scheme): Parsed { if (host && parts.length === 0) { // scheme://N (numeric) or scheme://owner (host-only, no repo segment) numberPart = host; - } else if (host && parts[0] === "diff") { - // pr://N/diff[/] — short form with diff suffix + } else if (scheme === "pr" && parts[0] === "diff" && parsePositiveDecimalInt(host) !== undefined) { + // pr://N/diff[/] — short form with diff suffix. Restrict this + // ambiguity to numeric hosts so `pr://owner/diff` remains the valid + // repo-scoped listing for a repository named `diff`. numberPart = host; diffParts = parts; } else if (host && parts.length === 1) { diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index 06cdb5d98..055e74d21 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -22,7 +22,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../we import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils"; import { applyListLimit } from "./list-limit"; import { formatStyledArtifactReference, type OutputMeta } from "./output-meta"; -import { formatExpandHint, getDomain } from "./render-utils"; +import { formatExpandHint, getDomain, replaceTabs } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; @@ -1362,14 +1362,28 @@ export function renderReadUrlCall( /** Render URL read result with tree-based layout */ export function renderReadUrlResult( - result: { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails }, + result: { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails; isError?: boolean }, options: RenderResultOptions, uiTheme: Theme = theme, ): Component { const details = result.details; - if (!details) { - return new Text(uiTheme.fg("error", "No response data"), 0, 0); + if (result.isError || !details) { + const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? ""; + const errorText = (rawErrorText || "No response data").replace(/^Error:\s*/, ""); + const urlText = details?.finalUrl ?? details?.url ?? ""; + const description = urlText ? `${getDomain(urlText)}${urlText.replace(/^https?:\/\/[^/]+/, "")}` : undefined; + const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme); + const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line))); + const outputBlock = new CachedOutputBlock(); + return { + render: (width: number) => + outputBlock.render( + { header, state: "error", sections: [{ lines: errorLines }], width }, + uiTheme, + ), + invalidate: () => outputBlock.invalidate(), + }; } const domain = getDomain(details.finalUrl); diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 19fd6abf6..53dc74ad2 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2674,27 +2674,136 @@ export function parsePrUnifiedDiff(text: string): PrDiffPayload { return { unified: text, files }; } + +interface ParsedDiffHeaderToken { + value: string; + nextIndex: number; +} + +function skipDiffHeaderSpaces(text: string, index: number): number { + let i = index; + while (text.charAt(i) === " ") i += 1; + return i; +} + +function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken { + const next = text.charAt(slashIndex + 1); + if (next === "") return { value: "\\", nextIndex: slashIndex + 1 }; + + if (next >= "0" && next <= "7") { + let end = slashIndex + 1; + while (end < text.length && end < slashIndex + 4) { + const digit = text.charAt(end); + if (digit < "0" || digit > "7") break; + end += 1; + } + return { + value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)), + nextIndex: end, + }; + } + + switch (next) { + case "a": + return { value: "\x07", nextIndex: slashIndex + 2 }; + case "b": + return { value: "\b", nextIndex: slashIndex + 2 }; + case "f": + return { value: "\f", nextIndex: slashIndex + 2 }; + case "n": + return { value: "\n", nextIndex: slashIndex + 2 }; + case "r": + return { value: "\r", nextIndex: slashIndex + 2 }; + case "t": + return { value: "\t", nextIndex: slashIndex + 2 }; + case "v": + return { value: "\v", nextIndex: slashIndex + 2 }; + case "\\": + case "\"": + return { value: next, nextIndex: slashIndex + 2 }; + default: + return { value: next, nextIndex: slashIndex + 2 }; + } +} + +function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined { + if (text.charAt(startIndex) !== "\"") return undefined; + let value = ""; + for (let i = startIndex + 1; i < text.length; i += 1) { + const ch = text.charAt(i); + if (ch === "\"") return { value, nextIndex: i + 1 }; + if (ch !== "\\") { + value += ch; + continue; + } + const escaped = parseDiffQuotedEscape(text, i); + value += escaped.value; + i = escaped.nextIndex - 1; + } + return undefined; +} + +function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined { + const start = skipDiffHeaderSpaces(text, startIndex); + if (start >= text.length) return undefined; + const quoted = parseDiffQuotedToken(text, start); + if (quoted) return quoted; + const end = text.indexOf(" ", start); + if (end === -1) return { value: text.slice(start), nextIndex: text.length }; + return { value: text.slice(start, end), nextIndex: end }; +} + +function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined { + return value.startsWith(prefix) ? value.slice(prefix.length) : undefined; +} + +function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } { + const trail = header.slice("diff --git ".length); + if (trail.startsWith("\"")) { + const oldToken = parseDiffQuotedToken(trail, 0); + if (!oldToken) return {}; + const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex); + if (!newToken) return {}; + return { + oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"), + newPath: stripPrDiffPathPrefix(newToken.value, "b/"), + }; + } + + const bIdx = trail.indexOf(" b/"); + if (trail.startsWith("a/") && bIdx > 0) { + return { + oldPath: trail.slice(2, bIdx), + newPath: trail.slice(bIdx + 3), + }; + } + return {}; +} + +function isPrDiffFileHeaderLine(line: string): boolean { + return ( + line === "--- /dev/null" || + line === "+++ /dev/null" || + line.startsWith("--- a/") || + line.startsWith("+++ b/") || + line.startsWith("--- \"a/") || + line.startsWith("+++ \"b/") + ); +} + function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile { const lines = section.split("\n"); const header = lines[0] ?? ""; - // `diff --git a/ b/` — paths may contain spaces, but gh emits - // them quoted with a leading `"`. We accept the common unquoted shape and - // fall back to the whole tail for quoted/exotic forms. - let oldPath: string | undefined; - let newPath: string | undefined; - const trail = header.slice("diff --git ".length); - const aIdx = trail.indexOf("a/"); - const bIdx = trail.indexOf(" b/"); - if (aIdx === 0 && bIdx > 0) { - oldPath = trail.slice(2, bIdx); - newPath = trail.slice(bIdx + 3); - } + const headerPaths = parsePrDiffHeaderPaths(header); + let oldPath = headerPaths.oldPath; + let newPath = headerPaths.newPath; let changeType: PrDiffFile["changeType"] = "modified"; let isBinary = false; let additions = 0; let deletions = 0; + let inHunk = false; for (let li = 1; li < lines.length; li += 1) { const line = lines[li] ?? ""; if (line.startsWith("new file mode")) { @@ -2718,8 +2827,11 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num isBinary = true; continue; } - // `+++ b/` / `--- a/` are headers, not content. - if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("@@ ")) { + inHunk = true; + continue; + } + if (!inHunk && isPrDiffFileHeaderLine(line)) continue; if (line.startsWith("+")) { additions += 1; } else if (line.startsWith("-")) { diff --git a/packages/coding-agent/src/tools/github-cache.ts b/packages/coding-agent/src/tools/github-cache.ts index 972555406..4bc072698 100644 --- a/packages/coding-agent/src/tools/github-cache.ts +++ b/packages/coding-agent/src/tools/github-cache.ts @@ -64,7 +64,6 @@ function ensureParentDir(filePath: string): void { try { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.chmodSync(dir, 0o700); } catch (err) { logger.debug("github cache: failed to create private parent dir", { err: String(err) }); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index e24e6fd73..7a47c9520 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -56,7 +56,7 @@ import { import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils"; -import { formatBytes, shortenPath, wrapBrackets } from "./render-utils"; +import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils"; import { executeReadQuery, getRowByKey, @@ -1806,7 +1806,7 @@ export const readToolRenderer = { }, renderResult( - result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails }, + result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails; isError?: boolean }, options: RenderResultOptions, uiTheme: Theme, args?: ReadRenderArgs, @@ -1814,12 +1814,39 @@ export const readToolRenderer = { const urlDetails = result.details as ReadUrlToolDetails | undefined; if (urlDetails?.kind === "url" || isReadableUrlPath(args?.file_path || args?.path || "")) { return renderReadUrlResult( - result as { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails }, + result as { + content: Array<{ type: string; text?: string }>; + details?: ReadUrlToolDetails; + isError?: boolean; + }, options, uiTheme, ); } + if (result.isError) { + const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? ""; + const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, ""); + const rawPath = args?.file_path || args?.path || ""; + const filePath = shortenPath(rawPath); + let title = filePath ? `Read ${filePath}` : "Read"; + if (args?.offset !== undefined || args?.limit !== undefined) { + const startLine = args.offset ?? 1; + const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; + title += `:${startLine}${endLine ? `-${endLine}` : ""}`; + } + const header = renderStatusLine({ icon: "error", title }, uiTheme); + const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line))); + const outputBlock = new CachedOutputBlock(); + return { + render: (width: number) => + outputBlock.render( + { header, state: "error", sections: [{ lines: errorLines }], width }, + uiTheme, + ), + invalidate: () => outputBlock.invalidate(), + }; + } const details = result.details; const rawText = result.content?.find(c => c.type === "text")?.text ?? ""; // Prefer structured `displayContent` from details when available so the TUI diff --git a/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts b/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts index 08826e5f1..97277cc64 100644 --- a/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts @@ -354,6 +354,21 @@ describe("issue:// / pr:// listing", () => { expect(args).toEqual(expect.arrayContaining(["--state", "open"])); }); + it("treats `diff` as a repository name in repo-scoped listing URLs", async () => { + const spy = vi.spyOn(git.github, "json").mockResolvedValue([] as never); + + const router = InternalUrlRouter.instance(); + await router.resolve("issue://owner/diff"); + await router.resolve("pr://owner/diff"); + + const issueArgs = spy.mock.calls[0]?.[1] as string[]; + const prArgs = spy.mock.calls[1]?.[1] as string[]; + expect(issueArgs.slice(0, 2)).toEqual(["issue", "list"]); + expect(prArgs.slice(0, 2)).toEqual(["pr", "list"]); + expect(issueArgs).toEqual(expect.arrayContaining(["--repo", "owner/diff"])); + expect(prArgs).toEqual(expect.arrayContaining(["--repo", "owner/diff"])); + }); + it("issue:// (no repo, no session) surfaces a friendly resolution error", async () => { // resolveDefaultRepoMemoized calls `gh repo view`; intercept it. vi.spyOn(git.github, "text").mockRejectedValue(new Error("not a git repository")); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 3e22b7287..f6bd524e5 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -6,7 +6,12 @@ import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { buildSearchDateQualifier, GithubTool, parseSearchDateBound } from "@oh-my-pi/pi-coding-agent/tools/gh"; +import { + buildSearchDateQualifier, + GithubTool, + parsePrUnifiedDiff, + parseSearchDateBound, +} from "@oh-my-pi/pi-coding-agent/tools/gh"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils"; @@ -151,6 +156,52 @@ async function expectedWorktreePath(home: string, primaryRoot: string, localBran return fs.realpath(path.join(home, ".omp", "wt", encoded, localBranch)); } +describe("parsePrUnifiedDiff", () => { + it("parses quoted diff headers instead of falling back to unknown paths", () => { + const diff = [ + 'diff --git "a/src/file with spaces.ts" "b/src/file with spaces.ts"', + "index 0000000..1111111 100644", + '--- "a/src/file with spaces.ts"', + '+++ "b/src/file with spaces.ts"', + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); + + const parsed = parsePrUnifiedDiff(diff); + + expect(parsed.files).toHaveLength(1); + expect(parsed.files[0]).toMatchObject({ + path: "src/file with spaces.ts", + additions: 1, + deletions: 1, + changeType: "modified", + }); + expect(parsed.files[0]?.oldPath).toBeUndefined(); + }); + + it("counts hunk lines whose content starts with file-header markers", () => { + const diff = [ + "diff --git a/src/headings.md b/src/headings.md", + "index 0000000..1111111 100644", + "--- a/src/headings.md", + "+++ b/src/headings.md", + "@@ -1 +1 @@", + "---- removed heading marker", + "++++ added heading marker", + ].join("\n"); + + const parsed = parsePrUnifiedDiff(diff); + + expect(parsed.files[0]).toMatchObject({ + path: "src/headings.md", + additions: 1, + deletions: 1, + changeType: "modified", + }); + }); +}); + describe("github tool", () => { afterEach(() => { vi.useRealTimers(); diff --git a/packages/coding-agent/test/tools/github-cache.test.ts b/packages/coding-agent/test/tools/github-cache.test.ts index 326dd5ffe..55df735a9 100644 --- a/packages/coding-agent/test/tools/github-cache.test.ts +++ b/packages/coding-agent/test/tools/github-cache.test.ts @@ -179,6 +179,20 @@ describe("github-cache db layer", () => { const db = openDb(); expect(db).not.toBeNull(); }); + + it("does not chmod an existing cache parent directory", async () => { + const parent = path.join(tempDir, "caller-owned-parent"); + await fs.mkdir(parent, { recursive: true, mode: 0o755 }); + await fs.chmod(parent, 0o755); + process.env.OMP_GITHUB_CACHE_DB = path.join(parent, "github-cache.db"); + resetCacheForTests(); + + const db = openDb(); + + expect(db).not.toBeNull(); + const stat = await fs.stat(parent); + expect(stat.mode & 0o777).toBe(0o755); + }); }); describe("getOrFetchView (TTL semantics)", () => {