From c10809faec4a8d982e9bd362cc593b0d55cb81e5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 5 Jun 2026 18:10:18 +0000 Subject: [PATCH] fix(github): accepted case-only run url repo matches resolveGitHubRepo rejected calls that supplied both an explicit repo and a full Actions run URL when the two owner/repo slugs differed only by casing. GitHub repository paths are case-insensitive, so this was the same class of false mismatch as the cwd guard fixed earlier. Compare repo slugs through a shared ASCII case-insensitive helper and use it for both the run-URL consistency check and the cwd guard. Added a regression test for repo=cagedbird043/cxf with a run URL under CagedBird043/CXF. Refs #1949 #1951 --- packages/coding-agent/src/tools/gh.ts | 17 ++++++++- packages/coding-agent/test/tools/gh.test.ts | 41 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index fef7a5359..d3b73cde1 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -792,6 +792,19 @@ function repoFromRepositoryUrl(value: string | undefined): string | undefined { return value.slice(REPO_API_URL_PREFIX.length); } +function githubRepoSlugEquals(left: string | undefined, right: string): boolean { + if (left === undefined || left.length !== right.length) return false; + for (let idx = 0; idx < left.length; idx += 1) { + let leftCode = left.charCodeAt(idx); + let rightCode = right.charCodeAt(idx); + if (leftCode >= 65 && leftCode <= 90) leftCode += 32; + if (rightCode >= 65 && rightCode <= 90) rightCode += 32; + if (leftCode !== rightCode) return false; + } + return true; +} + + function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined { if (!user) return undefined; const login = user.login ?? undefined; @@ -1646,7 +1659,7 @@ async function resolveGitHubRepo( runRepo: string | undefined, signal?: AbortSignal, ): Promise { - if (repo && runRepo && repo !== runRepo) { + if (repo && runRepo && !githubRepoSlugEquals(repo, runRepo)) { throw new ToolError("run URL repository does not match the provided repo"); } @@ -3443,7 +3456,7 @@ async function executeRunWatch( // 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?.toLowerCase() !== repo.toLowerCase()) { + if (!githubRepoSlugEquals(cwdRepo, repo)) { 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 04bfc8fef..73248c52a 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -1051,6 +1051,47 @@ describe("github tool", () => { expect(result.details?.repo).toBe(targetRepo); }); + it("accepts case-only differences between explicit `repo` and a run URL repo (PR #1951)", async () => { + const targetRepo = "cagedbird043/cxf"; + const runUrlRepo = "CagedBird043/CXF"; + const runId = 123; + const jsonSpy = vi + .spyOn(git.github, "json") + .mockResolvedValueOnce({ + id: runId, + name: "CI", + display_title: "case-only run URL repo match", + status: "completed", + conclusion: "success", + head_branch: "main", + created_at: "2026-06-05T10:00:00Z", + updated_at: "2026-06-05T10:05:00Z", + html_url: `https://github.com/${runUrlRepo}/actions/runs/${runId}`, + }) + .mockResolvedValueOnce({ total_count: 0, jobs: [] }); + const textSpy = vi + .spyOn(git.github, "text") + .mockRejectedValue(new Error("gh repo view must not be consulted when `repo` is explicit")); + + const tool = new GithubTool(createSession("/tmp/run-watch-run-url-casing")); + const result = await tool.execute("run-watch", { + op: "run_watch", + repo: targetRepo, + run: `https://github.com/${runUrlRepo}/actions/runs/${runId}`, + }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(textSpy).not.toHaveBeenCalled(); + for (const call of jsonSpy.mock.calls) { + const argv = call[1]; + const apiPath = argv.find(arg => arg.startsWith("/repos/")); + expect(apiPath).toContain(`/repos/${targetRepo}/`); + } + expect(text).toContain(`Repository: ${targetRepo}`); + expect(result.details?.repo).toBe(targetRepo); + }); + + it("fails fast when explicit `repo` differs from the cwd repo and no `branch`/`run` selector is given (issue #1949)", async () => { // Without a selector, the legacy code grabbed the cwd's HEAD SHA and // queried it against the explicit repo — yielding an unrelated commit