Skip to content

feat: add snippet CRUD tools#470

Open
renanliberato wants to merge 8 commits into
zereight:mainfrom
renanliberato:feat/snippets
Open

feat: add snippet CRUD tools#470
renanliberato wants to merge 8 commits into
zereight:mainfrom
renanliberato:feat/snippets

Conversation

@renanliberato
Copy link
Copy Markdown

@renanliberato renanliberato commented May 12, 2026

Adds list_snippets, get_snippet, create_snippet, update_snippet, and delete_snippet — supporting both project-scoped and personal snippets via optional project_id. Registered under a new non-default "snippets" toolset.

It seems well covered and tested:

  • unit tests
  • exhaustive LLM code review
  • llm session tests in a gitlab instance I have access with a prompt list ⬇️
Prompt Tool Result
P1 discover_tools ✅ snippets active
P2 create_snippet (personal, single-file) ✅ id=****
P3 get_snippet (no content) ✅ no content field
P4 get_snippet (with content) ✅ content correct
P5 update_snippet (title+visibility) ✅ updated
P6 update_snippet (content, no file_name) ✅ rejected at parse
P7 update_snippet (file_name only) ✅ rejected at parse
P8 update_snippet (file+content) + verify ✅ content updated
P9 list_snippets (personal) ✅ id/title/web_url present
P10 delete_snippet + 404 verify ✅ deleted, 404 confirmed
P11 create_snippet (mixed shape) ✅ rejected at parse
P12 create_snippet (no shape) ✅ rejected at parse
P13 create_snippet (multi-file, project) ✅ id=****, 2 files
P14 get_snippet multi-file (no content) ✅ no content on files
P15 get_snippet multi-file (with content) ✅ per-file content, no root content
P16 update one file + verify other unchanged ✅ selective update works
P17 list_snippets (project) ✅ id=**** present
P18 delete_snippet multi-file ✅ deleted
P19 double-delete 404 ✅ error propagated

My first pull request here, let me know anything I missed.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e5f431e08

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts Outdated
if (args.include_content) {
const files = snippet.files ?? [];
if (files.length > 1) {
const ref = snippet.default_branch ?? "main";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive snippet file ref instead of hardcoding "main"

When get_snippet is called with include_content=true for multi-file snippets, the code falls back to "main" if default_branch is absent, but the GitLab raw-file endpoint requires the correct :ref and the snippet payload does not guarantee a default_branch field. On instances/projects that still use another default branch (for example master) this produces 404s and breaks content retrieval for otherwise valid snippets.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressing it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the last commit 🙇

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee5e4724ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts Outdated
Comment on lines +8407 to +8409
const encodedRef = ref.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodedRef}/${encodedPath}/raw`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode snippet ref as a single URL path parameter

When building the /files/:ref/:file_path/raw URL, ref is encoded segment-by-segment and rejoined with /, which leaves slashes unescaped (for example, feature/foo stays feature/foo). In that case GitLab parses only feature as :ref and shifts the rest into :file_path, so get_snippet(include_content=true) can return 404 or wrong content for multi-file snippets whose ref contains / (common for branch names). Encode ref as one value (encodeURIComponent(ref)) before inserting it into the path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh yeah it's obvious, my bad (also why is my code reviewer not getting it? =/). Gonna handle it.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ff40ace3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts Outdated
filePath: string
): Promise<string> {
const encodedRef = encodeURIComponent(ref);
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode snippet file_path as a single path parameter

When fetching multi-file snippet content, filePath is encoded per segment and then rejoined with /, so directory separators remain unescaped. GitLab’s snippets file-content endpoint expects :file_path to be URL-encoded as one value; for files under subdirectories (for example docs/policy.md), this can cause 404s or path misrouting because the slash is treated as a path delimiter instead of part of file_path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

@zereight zereight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the snippet toolset. I found a few API-contract issues that should be fixed before merging.

  1. Nested multi-file snippet content fetch can 404

getSnippetFileRawContent() encodes the file path per path segment:

filePath.split("/").map(encodeURIComponent).join("/")

For a nested snippet file like dir/policy.md, the request stays as /files/main/dir/policy.md/raw. GitLab's snippets API expects file_path to be a URL-encoded path for the /files/:ref/:file_path/raw endpoint, so nested paths can be routed incorrectly or return 404.

Suggested fix: encode the entire path with encodeURIComponent(filePath) and add a test with a nested file path such as dir/policy.md.

  1. URL-encoded project paths are double-encoded

getSnippetsEndpoint() accepts project_id values described as either project IDs or URL-encoded paths, but it passes the value directly through encodeURIComponent. With project_id=group%2Fproject, the endpoint becomes /projects/group%252Fproject/snippets instead of the intended project path.

Suggested fix: follow the existing project endpoint pattern and decode before re-encoding, e.g. decodeURIComponent(projectId) before getEffectiveProjectId/encodeURIComponent.

  1. Project snippet responses may fail schema parsing

GitLabSnippetSchema requires visibility, but the Project Snippets API response shape does not consistently document/return that field. A valid project snippet response without visibility would fail with a ZodError across list/get/create/update paths.

Suggested fix: make visibility optional or split personal/project snippet response schemas.

  1. update_snippet does not expose the modern multi-file update contract

create_snippet supports files[], but update_snippet only exposes the legacy-ish file_name + content shape and converts it to a single action: "update" file. GitLab supports multi-file update actions such as create/update/delete/move with previous_path. As written, users can create multi-file snippets but cannot fully update/move/delete files through this tool.

Suggested fix: add a files[] update shape with action and previous_path support, while keeping file_name/content as a backward-compatible shortcut if desired.

renanliberato and others added 4 commits May 15, 2026 16:49
Adds list_snippets, get_snippet, create_snippet, update_snippet, and
delete_snippet — supporting both project-scoped and personal snippets
via optional project_id. Registered under a new non-default "snippets"
toolset.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
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>
getSnippetFileRawContent now takes an explicit ref string instead of a
raw_url to parse. The ref-extraction logic is factored into a standalone
extractSnippetRef helper, resolved once at the call site rather than
once per file inside the fetch function.

get_snippet also gains an optional ref parameter (matching get_file_contents)
so callers can override the ref directly without relying on raw_url parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Nested multi-file paths now encode the full file_path as a single segment
  (encodeURIComponent) per GitLab's /files/:ref/:file_path/raw contract.
  Previously, paths like dir/policy.md kept the slash and 404'd.
- getSnippetsEndpoint decodes URL-encoded project paths before re-encoding,
  matching the existing pattern in merge_requests/issues. Eliminates
  double-encoded URLs like /projects/group%252Fproject/snippets.
- GitLabSnippetSchema.visibility is now optional — the project snippets API
  does not consistently return it and ZodError was breaking list/get/create/
  update for valid responses.
- update_snippet exposes the modern multi-file contract: files[] with
  action (create/update/delete/move) and previous_path. The file_name+content
  shortcut remains for backward compatibility and is mutually exclusive.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a34d885f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts Outdated
Comment on lines +10583 to +10584
if (!firstFile.raw_url) throw new Error(`Snippet file "${firstFile.path}" has no raw_url`);
const ref = args.ref ?? extractSnippetRef(firstFile.raw_url, args.snippet_id, firstFile.path);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip raw_url guard when caller already provides ref

get_snippet(include_content=true) for multi-file snippets currently throws if files[0].raw_url is missing before checking args.ref, so requests that explicitly pass ref still fail even though no raw_url parsing is needed. This breaks valid calls on snippet payloads where raw_url is omitted/empty but path is present, and prevents content retrieval despite having all required inputs (snippet_id, ref, file_path).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still looks applicable at the latest head. args.ref is only used after the raw_url guard, so a caller who explicitly provides ref can still be rejected when the snippet file object has no raw_url.

Could we only require/parse raw_url when args.ref is absent? The effective ref could be const ref = args.ref ?? extractSnippetRef(firstFile.raw_url) with the raw_url check inside the fallback branch.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5314e83f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread schemas.ts
Comment on lines +3452 to +3456
previous_path: z
.string()
.optional()
.describe("Previous path of the snippet file (required for 'move')."),
content: z
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require previous_path for move snippet file actions

SnippetFileUpdateActionSchema declares previous_path as optional even though this same schema describes it as required for action: "move", so update_snippet currently accepts payloads like {action:"move", file_path:"new.md"} and only fails later with a GitLab 400. This weakens tool-level validation and makes LLM-generated update requests brittle, because malformed move operations are not rejected at parse time even though the required field is known locally.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, I didn't know zod had a way to handle it, but indeed has. Addressed.

SnippetFileUpdateActionSchema had previous_path optional even though the
schema description said it was required for action: "move". That left
malformed move actions to fail late with a GitLab 400 instead of being
caught at parse time. Adds a superRefine that surfaces the constraint as
a Zod error so LLM-generated update requests get a clear, local rejection.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68b4aa690a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts
const effectiveProjectId = getEffectiveProjectId(decoded);
return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/snippets`;
}
return `${getEffectiveApiUrl()}/snippets`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce allowlist before using global snippets endpoint

When project_id is omitted, getSnippetsEndpoint always returns /snippets, which bypasses getEffectiveProjectId and therefore skips GITLAB_ALLOWED_PROJECT_IDS enforcement. In restricted deployments, a caller can still invoke get_snippet/update_snippet/delete_snippet against global snippet IDs and reach snippets outside the configured project allowlist if the token can access them, which defeats the project-scoping guard.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zereight , could you enlighten me with any project standards? Not sure if other API besides Snippets have this "User or Project" behaviour. I'm not sure if an env var about projects should have influence over user snippets too.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this concern. In this repo, GITLAB_PROJECT_ID and GITLAB_ALLOWED_PROJECT_IDS are used as project-scope access boundaries, not just defaults. When either restriction is configured, calls that omit project_id should not fall back to the global /snippets endpoint, because that can read, update, or delete personal/global snippets outside the configured project scope.

I would either reject omitted project_id while project scoping is active, or explicitly default to the single configured project if that is the intended behavior. Personal snippets should only be available when no project-scope restriction is configured.

Comment thread schemas.ts
Comment on lines +3461 to +3468
.superRefine((data, ctx) => {
if (data.action === "move" && data.previous_path === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "previous_path is required for action: 'move'",
path: ["previous_path"],
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate required fields for each snippet file action

SnippetFileUpdateActionSchema only enforces previous_path for move, but it still accepts invalid actions like {"action":"create","file_path":"a.md"} (missing content) or {"action":"delete"} (missing file_path). Those payloads pass local validation and then fail with GitLab 400s, making tool behavior brittle and inconsistent with the schema descriptions that mark these fields as required for specific actions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, not sure how much exhaustive I should be on this. Any opinion @zereight ?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep this validation exhaustive. Since files[] is already modeling the action-specific GitLab contract, malformed actions should fail locally before becoming GitLab 400s.

A small matrix should be enough:

  • every action requires file_path
  • create and update require content
  • move requires both previous_path and the new file_path
  • delete requires file_path and does not need content
  • files[] should probably be non-empty when provided

That keeps LLM-generated tool calls predictable and matches the project pattern of surfacing schema errors before upstream API errors when the requirement is known locally.

Copy link
Copy Markdown
Owner

@zereight zereight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up fixes. The original API-contract issues I raised are mostly addressed now: nested snippet file paths are encoded correctly, URL-encoded project paths are decoded before re-encoding, visibility is no longer required for project snippet responses, and update_snippet now exposes the modern files[] update shape. The new test/test-snippets.ts file is also wired into test:mock.

I’m refreshing the request-changes baseline because a few issues remain:

  1. Project-scoped deployments still need a guard for omitted project_id. When GITLAB_PROJECT_ID or GITLAB_ALLOWED_PROJECT_IDS is configured, falling back to the global /snippets endpoint bypasses the project boundary. I replied on the existing thread with the expected project-standard behavior.

  2. get_snippet(include_content=true, ref=...) still requires raw_url before it checks the explicit ref, so valid calls can fail when the snippet file payload has path but no raw_url. I replied on the existing thread with the minimal shape.

  3. SnippetFileUpdateActionSchema should validate the action-specific required fields locally: file_path for every action, content for create/update, previous_path for move, and a non-empty files[] array when provided. I replied on the existing schema thread with the matrix.

Once these are addressed, this should be close to merge-ready.

get_snippet(include_content=true) previously threw if files[0].raw_url
was missing, even when the caller passed ref explicitly. Flip the guard
so raw_url is only required as a fallback for inferring ref.
getSnippetsEndpoint used to fall back to the global /snippets endpoint
whenever project_id was omitted, bypassing GITLAB_PROJECT_ID and
GITLAB_ALLOWED_PROJECT_IDS access boundaries. Route omitted project_id
through getEffectiveProjectId when either scope env is set, reusing its
existing default-when-unambiguous, reject-when-ambiguous semantics.
Personal snippets remain available only when no scope is configured.
SnippetFileUpdateActionSchema only enforced previous_path for 'move',
so malformed actions (create/update without content, delete without
file_path) and empty files[] arrays passed local validation and only
failed later as GitLab 400s. Expand the superRefine with the full
action matrix and require files[] to be non-empty when provided.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79eb6d6f57

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread index.ts
Comment on lines +10640 to +10643
result.content = await getSnippetRawContent(
args.project_id,
args.snippet_id
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor ref for single-file snippet content fetches

get_snippet ignores args.ref when files.length <= 1 and always calls the /raw endpoint, so callers requesting historical content by branch/tag/commit still receive default-branch HEAD content. GitLab exposes .../files/:ref/:file_path/raw for ref-specific reads, and this path is already used for multi-file snippets, so this is a functional mismatch that returns incorrect content whenever include_content=true, ref is set, and the snippet has one file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my, historical snippets content ? Gonna test and address it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants