fix: defensive guards for project_id coercion, labels query, and null MR response#438
fix: defensive guards for project_id coercion, labels query, and null MR response#438alfonsodg wants to merge 1 commit into
Conversation
…t, and null MR response - Guard getEffectiveProjectId against z.coerce.string() converting undefined/null to literal strings "undefined"/"null" - Fix labels query parameter to use comma-separated format per GitLab API docs instead of array notation (labels[]) - Add null coalescing for listMergeRequests response to prevent JSON.stringify(null) errors
There was a problem hiding this comment.
Pull request overview
This PR adds defensive handling for a few production edge cases in GitLab API tool calls, primarily around project ID resolution and query/response shaping.
Changes:
- Add guards in
getEffectiveProjectIdto treat"null"/"undefined"(and other falsy values) as missing project IDs and fall back to allowed/default project configuration. - Fix
listIssuesquery serialization solabelsis sent as a comma-separated string instead oflabels[]array notation. - Ensure
list_merge_requeststool output is always a JSON array by null-coalescing the response before stringification.
Comments suppressed due to low confidence (1)
index.ts:1304
- The new guard returns early when projectId is falsy, so the later
GITLAB_ALLOWED_PROJECT_IDS.length === 1 && !projectIdbranch is now unreachable. Consider removing the redundant!projectIdchecks in this whitelist block to avoid dead code and keep the control flow easier to reason about.
if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
// If there's only one allowed project, use it as default
if (GITLAB_ALLOWED_PROJECT_IDS.length === 1 && !projectId) {
return GITLAB_ALLOWED_PROJECT_IDS[0];
}
| url.searchParams.append(`${key}[]`, value.toString()); | ||
| } | ||
| if (key === "labels" && Array.isArray(value)) { | ||
| url.searchParams.append(key, value.join(",")); |
There was a problem hiding this comment.
When labels is an empty array, value.join(",") produces an empty string and this now appends labels= to the query string. Previously, an empty array resulted in no labels parameter at all. Consider skipping the param when the array is empty to preserve prior behavior and avoid API-specific ambiguity for an empty labels filter.
| url.searchParams.append(key, value.join(",")); | |
| if (value.length > 0) { | |
| url.searchParams.append(key, value.join(",")); | |
| } |
| return { | ||
| content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], | ||
| content: [{ type: "text", text: JSON.stringify(mergeRequests ?? [], null, 2) }], | ||
| }; |
There was a problem hiding this comment.
listMergeRequests() is typed to return GitLabMergeRequest[] and currently z.array(...).parse(data) will throw if the API returns null. Coalescing here to [] is likely redundant and can hide unexpected return shapes; consider handling a null/invalid response inside listMergeRequests (or adjusting the zod schema) so the behavior is consistent for all callers.
| // Guard against z.coerce.string() converting undefined/null to literal strings | ||
| if (!projectId || projectId === "undefined" || projectId === "null") { | ||
| if (GITLAB_ALLOWED_PROJECT_IDS.length === 1) { | ||
| return GITLAB_ALLOWED_PROJECT_IDS[0]; | ||
| } | ||
| if (GITLAB_ALLOWED_PROJECT_IDS.length > 1) { | ||
| throw new Error( | ||
| `Multiple projects allowed (${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}). Please specify a project ID.` | ||
| ); | ||
| } | ||
| if (GITLAB_PROJECT_ID) { | ||
| return GITLAB_PROJECT_ID; | ||
| } | ||
| throw new Error("No project ID provided and GITLAB_PROJECT_ID is not set"); |
There was a problem hiding this comment.
There are existing automated tests for getEffectiveProjectId (see test/test-geteffectiveprojectid.ts), but they don’t cover the new edge cases where inputs become the literal strings "null"/"undefined". Adding test cases for these values would help prevent regressions and validate the intended fallback/throw behavior across whitelist and default-project configurations.
| // Add all query parameters | ||
| Object.entries(options).forEach(([key, value]) => { | ||
| if (value !== undefined) { | ||
| const keys = ["labels", "assignee_username"]; | ||
| if (keys.includes(key)) { | ||
| if (Array.isArray(value)) { | ||
| // Handle array of labels | ||
| value.forEach(label => { | ||
| url.searchParams.append(`${key}[]`, label.toString()); | ||
| }); | ||
| } else if (value) { | ||
| url.searchParams.append(`${key}[]`, value.toString()); | ||
| } | ||
| if (key === "labels" && Array.isArray(value)) { | ||
| url.searchParams.append(key, value.join(",")); | ||
| } else if (key === "assignee_username" && Array.isArray(value)) { | ||
| value.forEach(v => url.searchParams.append(`${key}[]`, v.toString())); | ||
| } else { | ||
| url.searchParams.append(key, String(value)); | ||
| } |
There was a problem hiding this comment.
The query serialization for labels changed semantics (comma-separated string, and potential labels= for empty arrays). Adding a focused test that asserts the outgoing request URL contains labels=bug,feature (and omits labels for []) would lock in the intended GitLab API behavior.
| // Guard against z.coerce.string() converting undefined/null to literal strings | ||
| if (!projectId || projectId === "undefined" || projectId === "null") { | ||
| if (GITLAB_ALLOWED_PROJECT_IDS.length === 1) { | ||
| return GITLAB_ALLOWED_PROJECT_IDS[0]; | ||
| } |
There was a problem hiding this comment.
Special-casing the literal strings "undefined" and "null" means a real project whose ID/path is exactly "undefined" or "null" can no longer be targeted. If the intent is only to handle z.coerce.string() / null inputs, it’s safer to fix this at the schema boundary (e.g., preprocess null/"null" to undefined) or carry an undefined | string type into this function rather than reserving specific string values.
zereight
left a comment
There was a problem hiding this comment.
Left one comment about the null merge-request response handling.
| const mergeRequests = await listMergeRequests(project_id, cleanedOptions); | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], | ||
| content: [{ type: "text", text: JSON.stringify(mergeRequests ?? [], null, 2) }], |
There was a problem hiding this comment.
This does not actually handle the null response case described in the PR. listMergeRequests() still parses the response with z.array(GitLabMergeRequestSchema).parse(data) before returning, so if GitLab returns null, it will throw inside listMergeRequests() and this mergeRequests ?? [] fallback is never reached.
Could we move the fallback into listMergeRequests() itself, for example by normalizing data ?? [] before the zod array parse, or by changing the parser to explicitly accept null and return [] there? That keeps the behavior consistent for every caller instead of only this tool-call branch.
There was a problem hiding this comment.
Suggested shape:
const data = await response.json();
const normalizedData = data ?? [];
return z.array(GitLabMergeRequestSchema).parse(normalizedData);If we only want to tolerate null and still reject other unexpected shapes, this keeps the existing zod validation intact while making the documented null -> [] behavior actually reachable for every listMergeRequests() caller.
Summary
Three defensive fixes for edge cases encountered in production:
1. Guard
getEffectiveProjectIdagainst coerced string literalsz.coerce.string()convertsundefined/nullto the literal strings"undefined"/"null", which bypass the existing!projectIdfalsy check. This causes the function to treat them as real project IDs, leading to 404 errors from the GitLab API.Fix: Early return when projectId is falsy or equals
"undefined"/"null", falling back to allowed project IDs orGITLAB_PROJECT_ID.2. Fix labels query parameter format in
listIssuesThe GitLab API expects labels as a comma-separated string (
labels=bug,feature), not array notation (labels[]=bug&labels[]=feature). The array format causes the API to ignore the filter silently.Ref: https://docs.gitlab.com/api/issues.html#list-issues
Fix: Use
value.join(",")for labels while keeping[]notation forassignee_usernamewhich does expect array format.3. Null coalescing for
listMergeRequestsresponseWhen
listMergeRequestsreturnsnull(e.g., empty project or API edge case),JSON.stringify(null)produces the string"null"instead of a valid array.Fix:
mergeRequests ?? []ensures a valid JSON array is always returned.Testing
tsc --noEmit)