Merge PR #5424: fix(github): add dedicated repository file reads (@roboomp)

# Conflicts:
#	packages/coding-agent/src/prompts/tools/bash.md
#	packages/coding-agent/src/prompts/tools/read.md
This commit is contained in:
can1357
2026-07-18 21:03:24 +02:00
6 changed files with 78 additions and 3 deletions
+1
View File
@@ -776,6 +776,7 @@
- Fixed `/handoff` and auto-handoff skipping extension lifecycle hooks by emitting cancellable `session_before_switch` hooks and a `session_switch` with `reason: "handoff"` after the replacement session is ready ([#4434](https://github.com/can1357/oh-my-pi/issues/4434)).
- Fixed TTSR stream interrupts so only the tool call whose stream matched a rule receives the rule-named abort result; sibling tool-call placeholders now use a neutral abort reason ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
- Fixed auto-compaction dead-ending in a warning loop ("Compaction freed too little context to make progress") when the single most-recent turn is itself over budget so `prepareCompaction` has nothing to summarize (`findCutPoint` never cuts inside a tool result). This `!preparation` short-circuit never ran the artifact-backed `shake` elide rescue that #3786 added to the post-maintenance guard, so snapcompact/context-full maintenance paused with no attempt to shrink the oversized tail. The dead-end now runs the same elide pass, re-prepares on the shrunken branch, and falls through to a normal compaction when the tail became summarizable — only pausing (single warning) when nothing is elide-eligible. ([#4786](https://github.com/can1357/oh-my-pi/issues/4786))
- Fixed GitHub-hosted repository file reads falling back to `curl` by adding a dedicated `github` file-read operation and explicit tool-routing guidance ([#4805](https://github.com/can1357/oh-my-pi/issues/4805)).
## [16.3.11] - 2026-07-06
@@ -1,6 +1,7 @@
Runs commands in a persistent shell session.
Use ONLY for: single binary call or short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`).
GitHub repository file? MUST use `github` `file_read` when available; otherwise `read`. NEVER use `curl`/`wget`.
{{#if hasLaunch}}Services, watchers, debuggers, REPLs → `hub` (`op:"start"`).{{/if}}
{{#if hasEval}}Inline scripts, heredocs, shell control flow, `$(…)`, multi-stage pipelines, `&&`-chains, quote/JSON escaping → `eval` cells.{{else}}Inline scripts, heredocs, shell control flow, `$(…)`, multi-stage pipelines, `&&`-chains → purpose-built tool or checked-in script.{{/if}}
@@ -1,8 +1,9 @@
Op-based `gh` wrapper: repos, PRs, search, checkout, push, Actions watch. Read an issue/PR via `issue://<N>`/`pr://<N>`. PR diffs: `pr://<N>/diff` (file listing), `pr://<N>/diff/<i>` (file slice, 1-indexed), `pr://<N>/diff/all` (full diff).
Op-based `gh` wrapper: repos, repository files, PRs, search, checkout, push, Actions watch. Read an issue/PR via `issue://<N>`/`pr://<N>`. PR diffs: `pr://<N>/diff` (file listing), `pr://<N>/diff/<i>` (file slice, 1-indexed), `pr://<N>/diff/all` (full diff).
<instruction>
Pick op via `op`. Beyond the field descriptions, per op:
- `repo_view` — omit `repo` to view the current checkout.
- `file_read` — reads `path` from `repo`; omit `repo` for the current checkout and `branch` for its default branch.
- `pr_create` — `head` defaults to the current branch.
- `pr_checkout` — checks PR(s) out into dedicated git worktrees, not your working tree; pass an array of `pr` to batch multiple in one call.
- `pr_push` — requires the branch to have been checked out first via `op: pr_checkout`.
@@ -15,3 +16,7 @@ Pick op via `op`. Beyond the field descriptions, per op:
<output>
Concise summary per op. `run_watch` failures save full logs to a session artifact.
</output>
<critical>
GitHub-hosted repository file? MUST use `file_read`; NEVER `curl`/`wget`.
</critical>
@@ -3,6 +3,7 @@ Read files, directories, archives, SQLite, images, documents, internal resources
<instruction>
- SHOULD parallelize independent reads.
- SHOULD use `read` (not browser) for web content; browser only when `read` can't deliver.
- GitHub repository file? MUST use `github` `file_read` when available; otherwise `read`. NEVER use `curl`/`wget`.
</instruction>
## Selectors — append `:<sel>` to `path` (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`)
+40 -2
View File
@@ -249,6 +249,7 @@ const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "a
const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
"repo_view",
"file_read",
"search_issues",
"search_prs",
"search_code",
@@ -259,10 +260,11 @@ const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
const githubSchema = type({
op: type(
"'repo_view' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
"'repo_view' | 'file_read' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
).describe("github operation"),
"repo?": type("string").describe("owner/repo"),
"branch?": type("string").describe("branch"),
"path?": type("string").describe("repository-relative file path"),
"pr?": type("string | string[]").describe("pr number, url, or branch"),
"force?": type("boolean").describe("reset existing local branch"),
"forceWithLease?": type("boolean").describe("force-with-lease push"),
@@ -2455,7 +2457,7 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
const op = typeof rawOp === "string" ? rawOp : "";
return GITHUB_READONLY_OPS.has(op) ? "read" : "exec";
};
readonly summary = "Interact with GitHub issues, pull requests, and repositories";
readonly summary = "Interact with GitHub repositories, files, pull requests, and Actions";
readonly loadMode = "discoverable";
readonly label = "GitHub";
readonly description = prompt.render(githubDescription);
@@ -2480,6 +2482,8 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
switch (params.op) {
case "repo_view":
return executeRepoView(this.session, params, signal);
case "file_read":
return executeFileRead(this.session, params, signal);
case "pr_create":
return executePrCreate(this.session, params, signal);
case "pr_checkout":
@@ -2525,6 +2529,40 @@ async function executeRepoView(
return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
}
async function executeFileRead(
session: ToolSession,
params: GithubInput,
signal: AbortSignal | undefined,
): Promise<AgentToolResult<GhToolDetails>> {
const repo = await resolveGitHubRepo(session.cwd, normalizeOptionalString(params.repo), undefined, signal);
const filePath = requireNonEmpty(normalizeOptionalString(params.path), "path");
if (filePath.startsWith("/")) {
throw new ToolError("path must be repository-relative");
}
const branch = normalizeOptionalString(params.branch);
const endpointPath = filePath
.split("/")
.map(segment => encodeURIComponent(segment))
.join("/");
const args = [
"api",
`/repos/${repo}/contents/${endpointPath}`,
"--method",
"GET",
"-H",
"Accept: application/vnd.github.raw+json",
];
if (branch) {
args.push("-f", `ref=${branch}`);
}
const text = await git.github.text(session.cwd, args, signal, {
repoProvided: true,
trimOutput: false,
});
const sourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`;
return buildTextResult(text, sourceUrl, { repo, branch });
}
// ────────────────────────────────────────────────────────────────────────────
// Cached issue/PR view fetchers
//
@@ -438,6 +438,35 @@ describe("github tool", () => {
expect(text).toContain("Topics: cli, github");
});
it("reads repository files through the GitHub contents API", async () => {
const textSpy = vi.spyOn(git.github, "text").mockResolvedValue('{"version":"16.3.11"}\n');
const tool = new GithubTool(createSession());
const result = await tool.execute("file-read", {
op: "file_read",
repo: "can1357/oh-my-pi",
branch: "main",
path: "packages/coding-agent/package.json",
});
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toBe('{"version":"16.3.11"}\n');
expect(textSpy).toHaveBeenCalledWith(
"/tmp/test",
[
"api",
"/repos/can1357/oh-my-pi/contents/packages/coding-agent/package.json",
"--method",
"GET",
"-H",
"Accept: application/vnd.github.raw+json",
"-f",
"ref=main",
],
undefined,
{ repoProvided: true, trimOutput: false },
);
});
it("creates a pull request via gh and renders the resulting summary", async () => {
const textCalls: string[][] = [];
const textSpy = vi.spyOn(git.github, "text").mockImplementation(async (_cwd, args) => {