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
This commit is contained in:
roboomp
2026-06-05 18:10:18 +00:00
parent 1f32b8aaf9
commit c10809faec
2 changed files with 56 additions and 2 deletions
+15 -2
View File
@@ -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<string> {
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.`,
);
@@ -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