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
This commit is contained in:
roboomp
2026-06-05 18:06:39 +00:00
parent 8135c91f3d
commit 1f32b8aaf9
2 changed files with 37 additions and 2 deletions
+5 -2
View File
@@ -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.`,
);
@@ -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);
});
});