From 1f32b8aaf928249551ea428ee288ea3b4e9b01f2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 5 Jun 2026 18:06:39 +0000 Subject: [PATCH] fix(github): compared run_watch repo guard case-insensitively GitHub owner/repo slugs are case-insensitive; `gh repo view` returns the canonical casing while callers may pass any casing. The new guard used strict equality, so a caller in the correct repo who typed `owner/repo` while the canonical form was `Owner/Repo` was forced to pass a redundant `branch`/`run` selector. Normalize both sides via toLowerCase() before deciding the cwd is a different repository. Regression test covers the casing-only match. Refs #1949 #1951 --- packages/coding-agent/src/tools/gh.ts | 7 +++-- packages/coding-agent/test/tools/gh.test.ts | 32 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index f599606db..fef7a5359 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -3438,9 +3438,12 @@ async function executeRunWatch( // No branch/run selector — derive the commit from the current checkout, // but only when cwd actually points at `repo`. Otherwise we'd watch an // unrelated commit SHA against the explicit repo and silently stream a - // confident wrong-repo status (issue #1949). + // confident wrong-repo status (issue #1949). GitHub `owner/repo` slugs + // are case-insensitive — `gh repo view` returns the canonical casing + // while callers may pass any casing — so the equality check normalizes + // both sides before deciding the cwd is a different repo (PR #1951). const cwdRepo = await tryResolveCurrentRepo(session.cwd, signal); - if (cwdRepo !== repo) { + if (cwdRepo?.toLowerCase() !== repo.toLowerCase()) { throw new ToolError( `Cannot infer the watched commit for ${repo}: current checkout is ${cwdRepo ?? "not a GitHub repository"}. Pass \`branch\` or \`run\` to scope the watch.`, ); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 0b324f777..04bfc8fef 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -1085,4 +1085,36 @@ describe("github tool", () => { ); expect(jsonSpy).not.toHaveBeenCalled(); }); + + it("treats explicit `repo` and the cwd repo as matching when only casing differs (PR #1951)", async () => { + // `gh repo view --json nameWithOwner` returns the canonical GitHub casing. + // A caller who types `cagedbird043/cxf` while the canonical form is + // `CagedBird043/cxf` MUST be treated as the same repo — GitHub repository + // paths are case-insensitive — and run_watch must NOT force them to pass + // a redundant `branch`/`run` selector. + const canonicalRepo = "CagedBird043/CXF"; + const userRepo = "cagedbird043/cxf"; + const cwd = `/tmp/run-watch-explicit-repo-casing-${Date.now()}`; + vi.spyOn(git.github, "text").mockResolvedValue(canonicalRepo); + // Past the case-insensitive guard, run_watch keeps using the caller's + // `repo` (downstream `/repos/...` paths are case-insensitive on GitHub). + // Stub the cwd's git HEAD/branch lookups so the watch proceeds to its + // first poll, then trip an abort to terminate the loop deterministically. + vi.spyOn(git.branch, "current").mockResolvedValue("main"); + vi.spyOn(git.head, "sha").mockResolvedValue("c215f3a91217c215f3a91217c215f3a91217c215"); + const abort = new AbortController(); + const jsonSpy = vi.spyOn(git.github, "json").mockImplementation((async () => { + abort.abort(); + return { workflow_runs: [] }; + }) as unknown as typeof git.github.json); + + const tool = new GithubTool(createSession(cwd)); + // We don't care about the outcome — just that the casing guard let us + // reach the polling loop instead of throwing the mismatch ToolError. + await tool.execute("run-watch", { op: "run_watch", repo: userRepo }, abort.signal).catch(() => {}); + + expect(jsonSpy).toHaveBeenCalled(); + const firstCall = jsonSpy.mock.calls[0]?.[1] as string[]; + expect(firstCall.some(arg => arg === `/repos/${userRepo}/actions/runs`)).toBe(true); + }); });