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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user