diff --git a/server/src/__tests__/gitea-skills.test.ts b/server/src/__tests__/gitea-skills.test.ts index 8e512996..ab999554 100644 --- a/server/src/__tests__/gitea-skills.test.ts +++ b/server/src/__tests__/gitea-skills.test.ts @@ -186,12 +186,12 @@ describe("resolveGiteaPinnedRef", () => { expect(result).toEqual({ pinnedRef: sha, trackingRef: sha }); }); - it("resolves a branch ref to its commit SHA via the commits endpoint", async () => { + it("resolves a branch ref to its commit SHA via the branches endpoint", async () => { const branchSha = "fedcba9876543210fedcba9876543210fedcba98"; const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ default_branch: "main" })) - .mockResolvedValueOnce(jsonResponse({ sha: branchSha })); + .mockResolvedValueOnce(jsonResponse({ name: "main", commit: { id: branchSha } })); vi.stubGlobal("fetch", fetchMock); const result = await resolveGiteaPinnedRef( parseGiteaSourceUrl("https://git.example.com/acme/skills"), @@ -202,13 +202,23 @@ describe("resolveGiteaPinnedRef", () => { }); describe("resolveGiteaCommitSha", () => { - it("returns the sha from a commit response", async () => { + it("returns the sha from a commit response when given a 40-hex ref", async () => { + const sha = "abc123abc123abc123abc123abc123abc123abcd"; vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue(jsonResponse({ sha: "abc123abc123abc123abc123abc123abc123abcd" })), + vi.fn().mockResolvedValue(jsonResponse({ sha })), ); - const sha = await resolveGiteaCommitSha("acme", "skills", "main", giteaApiBase("git.example.com")); - expect(sha).toBe("abc123abc123abc123abc123abc123abc123abcd"); + const result = await resolveGiteaCommitSha("acme", "skills", sha, giteaApiBase("git.example.com")); + expect(result).toBe(sha); + }); + + it("refuses to call the API for a non-SHA ref", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect( + resolveGiteaCommitSha("acme", "skills", "main", giteaApiBase("git.example.com")), + ).rejects.toMatchObject({ status: 422 }); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/gitea-skills.ts b/server/src/services/gitea-skills.ts index 294b36f4..a67f58f6 100644 --- a/server/src/services/gitea-skills.ts +++ b/server/src/services/gitea-skills.ts @@ -162,6 +162,11 @@ export async function resolveGiteaCommitSha( ref: string, apiBase: string, ): Promise { + if (!/^[0-9a-f]{40}$/i.test(ref.trim())) { + throw unprocessable( + `Gitea /commits endpoint only resolves SHAs; got "${ref}". Use fetchGiteaBranch for branch names.`, + ); + } const response = await fetchGiteaJson( `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, ); @@ -191,7 +196,13 @@ export async function resolveGiteaPinnedRef(parsed: GiteaSourceUrl): Promise<{ const trackingRef = parsed.explicitRef ? parsed.ref : await resolveGiteaDefaultBranch(parsed.owner, parsed.repo, apiBase); - const pinnedRef = await resolveGiteaCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase); + // Gitea's /repos/{o}/{r}/commits/{ref} endpoint only resolves SHAs — a branch + // name returns 404. The branches endpoint accepts both branch names and tags. + const branch = await fetchGiteaBranch(apiBase, parsed.owner, parsed.repo, trackingRef); + const pinnedRef = asString(branch.commit?.id); + if (!pinnedRef) { + throw unprocessable(`Failed to resolve Gitea ref ${trackingRef}`); + } return { pinnedRef, trackingRef }; }