Skip to content

Commit ee5e472

Browse files
renanliberatoclaude
andcommitted
fix: extract ref from raw_url to fetch multi-file snippet content
The multi-file content endpoint requires a ref to construct the API URL. Using default_branch caused 404s on instances where the default branch is not "main"; fetching raw_url directly also 404s because it is a web URL, not an API endpoint. GitLab embeds the correct ref inside each file's raw_url (.../raw/{ref}/{path}). Parse it out and pass it to the REST API endpoint (/snippets/{id}/files/{ref}/{path}/raw) instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7e5f431 commit ee5e472

3 files changed

Lines changed: 92 additions & 23 deletions

File tree

index.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8380,19 +8380,35 @@ async function getSnippetRawContent(
83808380
}
83818381
83828382
/**
8383-
* Get the raw content of a specific file inside a (possibly multi-file) snippet.
8383+
* Get the raw content of a specific file inside a multi-file snippet.
8384+
* Extracts the ref from the file's raw_url (e.g. .../raw/main/file.md) so we
8385+
* never have to guess it, then hits the proper REST API endpoint.
83848386
*/
83858387
async function getSnippetFileRawContent(
83868388
projectId: string | undefined,
83878389
snippetId: number,
8388-
ref: string,
8390+
rawUrl: string,
83898391
filePath: string
83908392
): Promise<string> {
8391-
const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodeURIComponent(ref)}/${encodeURIComponent(filePath)}/raw`;
8393+
// Anchor on the unambiguous /snippets/{id}/raw/ segment so branch names and
8394+
// file paths containing the word "raw" don't confuse the extraction.
8395+
const rawMarker = `/snippets/${snippetId}/raw/`;
8396+
const decoded = decodeURIComponent(new URL(rawUrl).pathname);
8397+
const markerIdx = decoded.indexOf(rawMarker);
8398+
if (markerIdx === -1) {
8399+
throw new Error(`Cannot extract ref from snippet file raw_url: ${rawUrl}`);
8400+
}
8401+
const afterRaw = decoded.slice(markerIdx + rawMarker.length); // "{ref}/{filePath}"
8402+
const fileStart = afterRaw.lastIndexOf("/" + filePath);
8403+
if (fileStart === -1) {
8404+
throw new Error(`Cannot locate file path "${filePath}" in snippet file raw_url: ${rawUrl}`);
8405+
}
8406+
const ref = afterRaw.slice(0, fileStart);
8407+
const encodedRef = ref.split("/").map(encodeURIComponent).join("/");
8408+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
8409+
const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodedRef}/${encodedPath}/raw`;
83928410
const response = await fetch(url, { ...getFetchConfig() });
8393-
83948411
await handleGitLabError(response);
8395-
83968412
return await response.text();
83978413
}
83988414
@@ -10554,17 +10570,11 @@ async function handleToolCall(params: any) {
1055410570
if (args.include_content) {
1055510571
const files = snippet.files ?? [];
1055610572
if (files.length > 1) {
10557-
const ref = snippet.default_branch ?? "main";
1055810573
result.files = await Promise.all(
10559-
files.map(async f => ({
10560-
...f,
10561-
content: await getSnippetFileRawContent(
10562-
args.project_id,
10563-
args.snippet_id,
10564-
ref,
10565-
f.path
10566-
),
10567-
}))
10574+
files.map(async f => {
10575+
if (!f.raw_url) throw new Error(`Snippet file "${f.path}" has no raw_url`);
10576+
return { ...f, content: await getSnippetFileRawContent(args.project_id, args.snippet_id, f.raw_url, f.path) };
10577+
})
1056810578
);
1056910579
} else {
1057010580
result.content = await getSnippetRawContent(

schemas.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3492,7 +3492,6 @@ export const GitLabSnippetSchema = z
34923492
file_name: z.string().nullable().optional(),
34933493
files: z.array(GitLabSnippetFileSchema).optional(),
34943494
project_id: z.number().nullable().optional(),
3495-
default_branch: z.string().optional(),
34963495
})
34973496
.passthrough();
34983497

test/test-snippets.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const TEST_PROJECT_ID = "456";
88
const TEST_PROJECT_SNIPPET_ID = 42;
99
const TEST_PERSONAL_SNIPPET_ID = 99;
1010
const TEST_MULTIFILE_SNIPPET_ID = 77;
11+
const TEST_MASTER_SNIPPET_ID = 88;
1112
const RAW_CONTENT = "console.log('hello world');\n";
1213
const MULTIFILE_A_CONTENT = "# policy\nbody A\n";
1314
const MULTIFILE_B_CONTENT = "# instructions\nbody B\n";
@@ -191,10 +192,15 @@ describe("snippet tools", () => {
191192
id: TEST_MULTIFILE_SNIPPET_ID,
192193
title: "Multi-file snippet",
193194
file_name: null,
194-
default_branch: "main",
195195
files: [
196-
{ path: "policy.md", raw_url: "https://gitlab.example.com/raw/policy" },
197-
{ path: "instructions.md", raw_url: "https://gitlab.example.com/raw/instructions" },
196+
{
197+
path: "policy.md",
198+
raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MULTIFILE_SNIPPET_ID}/raw/main/policy.md`,
199+
},
200+
{
201+
path: "instructions.md",
202+
raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MULTIFILE_SNIPPET_ID}/raw/main/instructions.md`,
203+
},
198204
],
199205
})
200206
);
@@ -204,19 +210,53 @@ describe("snippet tools", () => {
204210
mockGitLab.addMockHandler(
205211
"get",
206212
`/projects/${TEST_PROJECT_ID}/snippets/${TEST_MULTIFILE_SNIPPET_ID}/files/main/policy.md/raw`,
207-
(_req, res) => {
208-
res.type("text/plain").send(MULTIFILE_A_CONTENT);
209-
}
213+
(_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); }
210214
);
211215

212216
mockGitLab.addMockHandler(
213217
"get",
214218
`/projects/${TEST_PROJECT_ID}/snippets/${TEST_MULTIFILE_SNIPPET_ID}/files/main/instructions.md/raw`,
219+
(_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); }
220+
);
221+
222+
// --- Snippet with master default branch (raw_url contains "master") ---
223+
mockGitLab.addMockHandler(
224+
"get",
225+
`/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}`,
215226
(_req, res) => {
216-
res.type("text/plain").send(MULTIFILE_B_CONTENT);
227+
// No default_branch field — simulates an instance where it's absent
228+
res.json(
229+
buildSnippet({
230+
id: TEST_MASTER_SNIPPET_ID,
231+
title: "Master branch snippet",
232+
file_name: null,
233+
files: [
234+
{
235+
path: "policy.md",
236+
raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MASTER_SNIPPET_ID}/raw/master/policy.md`,
237+
},
238+
{
239+
path: "instructions.md",
240+
raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MASTER_SNIPPET_ID}/raw/master/instructions.md`,
241+
},
242+
],
243+
})
244+
);
217245
}
218246
);
219247

248+
mockGitLab.addMockHandler(
249+
"get",
250+
`/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}/files/master/policy.md/raw`,
251+
(_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); }
252+
);
253+
254+
mockGitLab.addMockHandler(
255+
"get",
256+
`/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}/files/master/instructions.md/raw`,
257+
(_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); }
258+
);
259+
220260
// --- Personal snippet handlers ---
221261
mockGitLab.addMockHandler("get", "/snippets", (_req, res) => {
222262
res.json([
@@ -431,6 +471,26 @@ describe("snippet tools", () => {
431471
});
432472
});
433473

474+
test("get_snippet fetches multi-file content from raw_url regardless of branch name", async () => {
475+
const result = await callTool(
476+
"get_snippet",
477+
{
478+
project_id: TEST_PROJECT_ID,
479+
snippet_id: TEST_MASTER_SNIPPET_ID,
480+
include_content: true,
481+
},
482+
env()
483+
);
484+
485+
assert.strictEqual(result.id, TEST_MASTER_SNIPPET_ID);
486+
assert.ok(Array.isArray(result.files));
487+
assert.strictEqual(result.files.length, 2);
488+
assert.strictEqual(result.files[0].path, "policy.md");
489+
assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT);
490+
assert.strictEqual(result.files[1].path, "instructions.md");
491+
assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT);
492+
});
493+
434494
test("create_snippet supports multi-file snippets via files[]", async () => {
435495
const result = await callTool(
436496
"create_snippet",

0 commit comments

Comments
 (0)