feat: fix the defects and gaps recorded against the GitLab API

What does this MR do?

I maintain gitlab-mcp-server, an MCP server built on this library. While mirroring GitLab's API one to one through it, I recorded every place where this library and GitLab disagree, and this is the single merge request we agreed on in #2300 for what the server needs: 37 commits on v3.14.0, one per recorded defect and one from the review, each with its own tests (except commit 32, which only marks fields deprecated) and, in its message, the GitLab source that proves it, so they can be reviewed one at a time, and I will split any of them into a merge request of its own if you prefer.

Each commit answers an entry of upstream-bugs.md, the file where gitlab-mcp-server keeps a record of everything it finds and contributes in the projects it depends on and in sibling projects. The Rust MCP SDK is an example of the second kind: the server does not use it, but its float decoding fix is what Codex needed to talk to MCP servers correctly.

Is this a breaking change?

Yes, in three ways, each of which gorelease -base=v3.14.0 reports. After the review, the CIRestrictPipelineCancellationRole fields of Project and EditProjectOptions take a role type of their own (commit 37), so code that holds the role in an AccessControlValue stops compiling; GitLab's Terraform provider is such code: its AccessControlLevelValueToName helper and four test lines hold the role that way. Deployment, Note and PlanLimit stop being comparable with == (decision 3 below). And new methods join their service interfaces, as every new method here does. No exported name is removed or renamed and no method changes signature. Where the right fix would remove a name, change a signature or change any other field's type, the commit does the half that does not, and the rest is listed under the breaking changes below for 4.0, which #2301 tracks.

Four things I asked you to decide

The review answered the first, third and fourth. For the second, my reply there explains the change, and it waits on your answer.

  1. A nil options pointer (commit 2). A nil *Options held in an any is not nil, so NewRequestToURL sends the four bytes null as the body of every POST, PUT or PATCH given one. No one can know whether a caller relies on the null, so commit 2 does not change the default: it adds the client option WithNilOptionsOmitted(), off by default. #2301 tracks making it the default in 4.0, where the option has no effect, and removing the option in 5.0.
  2. The eight security attribute and category mutations return *GraphQLResponseError (commit 4) for a refusal GitLab answers with HTTP 200 and a top-level errors array. Before, five of them returned success and three returned ErrNotFound, so a caller matching ErrNotFound on those three sees a different error.
  3. Deployment, Note and PlanLimit stop being comparable with == (commits 12, 16 and 31), because each gains a slice or a map GitLab sends. gorelease -base=v3.14.0 reports it. Comparability is not something this library treats as part of its compatibility, so the fields stay.
  4. Work item get, create and update keep their default selection (commit 10). They now take ReturnedFields, but the default still selects the five Enterprise-only widgets, so on Community Edition a caller passes WorkItemDefaultListFields(). The default stays Enterprise, which was decided with the team that added the API for the glab CLI.
The 37 commits, in branch order

In branch order. Each link is the GitLab source or documentation the commit rests on; the commit message carries the rest.

  1. fix(application_statistics): decode the counts GitLab sends as strings. The entity renders every count through number_with_delimiter, so GET /application/statistics answers "issues": "1,234" (with the separator of the caller's language) and the int64 fields could not decode it. An UnmarshalJSON now accepts a number or such a string. application_statistics.rb
  2. feat(client): add option to treat nil options pointers as no options. A nil *Options held in an any is not nil, so NewRequestToURL sends the four bytes null as the body of a POST, PUT or PATCH, and replaces a query already on the URL for any other method; CancelJob and PublishAllDraftNotes reach it on their own through their WithOptions siblings. WithNilOptionsOmitted() makes such a pointer mean no options. It is off by default, so no request changes unless a caller opts in. Go FAQ
  3. fix(sidekiq_metrics): drop the leading slash from the four routes. Every call went to /api/v4//sidekiq/..., which works on gitlab.com only because it redirects. sidekiq_metrics.rb
  4. fix: return the top-level GraphQL errors of eight security mutations. GitLab refuses the five security attribute and three security category mutations with HTTP 200, a top-level errors array and a null payload, and the methods reported that as success or as a bare ErrNotFound. They now return a *GraphQLResponseError, as the work item methods do. authorize_resource.rb
  5. fix(orbit): send the schema format as response_format. The route reads response_format, and Grape refuses format=raw and format=llm with 406 before the route runs, so GetOrbitSchema could only ever get the default answer. OrbitSchema also gains the formatted_text the llm format answers with. orbit/data.rb
  6. feat: add the Dependency Firewall operation and enablement endpoint. EvaluatePackageOptions gains the optional operation param, and GetDependencyFirewallEnablement wraps GET /projects/:id/dependency_firewall/enablement. enablement.rb
  7. feat(achievements): add namespace, user objects and AwardMessageHTML. The fragments selected the namespace and the three users as { id } alone and never selected awardMessageHtml. The IDs stay and the objects join them. user_achievement_type.rb
  8. feat(member_roles): add the 25 permissions MemberRole does not model. The entity sends all 45 customizable permissions on every response, and the struct had 20. (#2300) member_role.rb
  9. fix(group_relations_export): decode a relation status, add object count. With relation set the status route answers one object, which ListExportStatus failed to decode as a list; it now accepts both, and GroupRelationStatus gains TotalObjectsCount. group_export.rb
  10. fix(workitems): let get, create and update leave out EE-only fields. The three documents selected five widgets only the Enterprise schema has, so Community Edition refused them on every call. They now take the ReturnedFields the listing already has, with the old selection as the default. features_type.rb
  11. fix(epics): decode label details and add the fields GitLab sends. With with_labels_details the labels arrive as objects and every listing failed to decode. Epic now keeps the names in Labels and the objects in LabelDetails, as Issue and MergeRequest do, and gains the keys the entity sends; the list options gain author_username and confidential. epic.rb
  12. feat(notes): model suggestions, quick action changes and thread state. Note gains commands_changes and suggestions (the only way to read the ID a suggestion is applied by) among others, and Discussion gains resolvable and resolved. note.rb
  13. feat(issue_links): add the issue fields a listed relation carries. The links route renders a whole issue plus the link keys, and IssueRelation modelled little more than a basic issue. (#2300) related_issue.rb
  14. feat(merge_requests): add the HTML fields and blocked_merge_request. RenderHTML had nothing to land in, and MergeRequestDependency carried the blocking half of a dependency and not the blocked one. (#2300) merge_request_dependency.rb
  15. feat(commits): add collapsed, too_large and generated_file to Diff. Without them a diff GitLab emptied for its size reads as a file whose content did not change. diff.rb
  16. feat(deployments): return the approval GitLab records for a deployment. ApproveOrRejectProjectDeploymentV2 returns the approval the route answers with, and Deployment gains the approval fields the Enterprise Edition adds. deployments.rb
  17. feat: add fields GitLab sends that eight response structs miss. Key, SSHKey, Runner, RunnerDetails, ProjectLintResult (the jobs its own include_jobs option asks for), Label, Pipeline and PipelineTrigger. (#2300) ci/lint/result.rb
  18. feat(merge_requests): return the whole pipeline a merge request runs. The create route answers with Ci::Pipeline, which PipelineInfo decodes minus twelve keys. CreateMergeRequestPipelineV2 returns *Pipeline and takes the route's async option. (#2300) merge_requests.rb
  19. feat: add the enum values GitLab accepts and a cancellation role type. EventTypeValue, EventTargetTypeValue, TodoAction and DeploymentStatusValue lacked values GitLab validates against, and CIRestrictPipelineCancellationRole was typed with the feature access levels. todo.rb
  20. feat(projects): return the project a fork relationship is created on. The route answers with the project, which was decoded into a ProjectForkRelation shape no entity exposes. CreateProjectForkRelationV2 returns *Project. projects.rb
  21. feat(projects): return the link a project shared with a group gets. ShareProjectWithGroup discarded the ProjectGroupLink the route answers 201 with, and ShareProjectWithGroupV2 returns it. project_group_link.rb
  22. feat: add fields GitLab sends that Group, Project and Issue miss. Also ProjectUser, ProjectApprovalRule and GroupHook. Each conditional field's comment says when GitLab sends it. (#2300) group_detail.rb
  23. feat: add five response keys and three parameters from GitLab 19.4. duo_flow_callback_enabled on hooks and their options, ci_skip_branch_pipelines_for_mrs on projects, provisioned_by_project_id on users and ci_minutes_usage on namespaces. project_hook.rb
  24. feat(jobs): add the five pipeline keys JobPipeline misses. A job's pipeline is rendered with Ci::PipelineBasic, ten keys, and the struct had five. pipeline_basic.rb
  25. feat(award_emojis): add the url of a custom emoji to AwardEmoji. Without it a custom emoji is a name with no image. award_emoji.rb
  26. feat: add the token fields GitLab sends that the token structs miss. granular, granular_scopes and last_used_ips on personal access tokens, and what the impersonation and resource access token entities add to them. personal_access_token.rb
  27. feat(users): add the fifteen user keys GitLab sends that User misses. Also CreateServiceAccountUserV2, since POST /service_accounts answers with a service account rather than a user. (#2300) user.rb
  28. feat(invites): add invite_source, member_role_id and queued_users. Without queued_users, an invitee held for an administrator's approval reads as a success. create_service.rb
  29. feat: add keys GitLab sends on every object that seven structs miss. PackagePipeline, BasicUser, BillableGroupMember, ServiceAccount, ProjectServiceAccount, PendingInvite and AccessRequest; LicenseTemplate.Featured, which no entity sends, is deprecated. (#2300) access_requester.rb
  30. feat(members): add what GitLab sends, accepts and serves for members. The member fields, the skip_users, state and invite_source params, DeleteProjectMemberWithOptions, and seven Enterprise member routes no method reached. members.rb
  31. feat(plan_limits): add the limits and history GitLab sends and accepts. PlanLimit and ChangePlanLimitOptions modelled eight of the twenty-nine limits. plan_limit.rb
  32. docs: deprecate the struct fields that no GitLab entity sends. Each decodes to its zero value on every call, and each now has a Deprecated note saying why, and what to read instead where GitLab sends the value under another name, for example the event title and data that Entities::Event sends as target_title and push_data. event.rb
  33. fix(geo_sites): decode namespace objects and add what GitLab sends. On a site that syncs selectively by namespace the two status methods failed to decode. Also the per-replicable status keys as a map, four site settings, and RepairGeoSiteV2, since the repair route answers with a status rather than a site. (#2300) geo_site_status.rb
  34. fix(feature_flags): omit unset gates from SetFeatureFlagOptions. The empty gates collide as mutually exclusive, so every call was refused with 400, a plain instance-wide boolean gate included. features.rb
  35. fix(protected_packages): omit unset fields from a rule update. A partial update sent the pattern and the type as null and was refused with 422. project_packages_protection_rules.rb
  36. fix: omit unset optional params from seven more option structs. The same class as the two before it, found by comparing each option struct's always-written keys with the params GitLab marks optional; on the pipeline schedule variable edit the stray null overwrites the stored value. pipeline_schedules.rb
  37. fix(projects): make the pipeline cancellation role a type of its own. From the review: CIRestrictPipelineCancellationRoleValue stops being an alias of AccessControlValue, none of whose constants the setting accepts, so code that holds the role in an AccessControlValue no longer compiles. project_ci_cd_setting.rb
Breaking changes I left out, for 4.0

These are the halves that would break v3, so they are not done here. #2301 tracks them for 4.0, together with the review's additions:

  • Six methods return a type their endpoint does not send: CreateProjectForkRelation (a project, decoded as ProjectForkRelation), ShareProjectWithGroup and ApproveOrRejectProjectDeployment (a link and an approval, both discarded), CreateMergeRequestPipeline (a whole pipeline, decoded as PipelineInfo), CreateServiceAccountUser (a service account, decoded as User) and RepairGeoSite (a site status, decoded as GeoSite). Each has a V2 sibling with the right return type and is marked deprecated in its favour. In 4.0 the old name takes the V2 signature, the V2 goes, and so does ProjectForkRelation.
  • A nil options pointer still sends null by default (commit 2). WithNilOptionsOmitted() becomes the default in 4.0, where the option has no effect, and the option is removed in 5.0.
  • Two methods take no options although their routes do: GetWorkItem and DeleteProjectMember. Each has a WithOptions sibling, the pattern CancelJob and PublishAllDraftNotes already follow, until 4.0 folds the options in.
  • Two fields are typed narrower than what GitLab sends: GeoSiteStatus.Namespaces is a []string for NamespaceBasic objects, and Epic.Labels a []string for the label objects with_labels_details returns. Both keep their type: an UnmarshalJSON keeps the names there and puts the objects in a new field beside them (NamespaceDetails, LabelDetails). For labels this is the shape Issue and MergeRequest already have; for Geo, changing the field's type is the 4.0 half.
  • Fields no GitLab entity sends, and option fields no route declares, are deprecated rather than removed: the ones in commit 32, the four note fields and the author and resolver Email of commit 12, Epic.UserNotesCount and LicenseTemplate.Featured. Removing them is the 4.0 half.
  • Some structs decode more than one entity. Group, Project and Issue each decode a narrow Grape entity and one or more wider ones that inherit it (for Issue, IssueBasic where other APIs render an issue and Issue on the issues API's own routes), and User decodes six. Splitting them would change what the methods return, so the new fields go on the one struct and their comments say when GitLab sends each.
  • GetMergeRequestChanges and ChangeApprovalConfiguration return *MergeRequest for endpoints that answer with MergeRequestChanges and ApprovalState. GitLab deprecated both endpoints and both methods are already marked deprecated here, so I left them alone.

gorelease -base=v3.14.0 reports three kinds of incompatible change: the comparability of decision 3, the two field type changes of commit 37, and the new methods added to their service interfaces. The last breaks a type outside this library that implements one of those interfaces; that is how every new method lands here (GetProjectPackage joined PackagesServiceInterface the same way in v3.14.0), and the mocks in testing are regenerated for each.

Not in this MR
  • The seven Hook fields, which !3048 (merged) added in v3.15.0.
  • DetailedStatus still lacks the action object and the size, title and content of the illustration that GitLab's DetailedStatusEntity renders. It needs a type of its own, so I kept it out of commit 18.
  • GetNamespace answering with an array, which the record also lists: GET /namespaces/:id presents one Entities::Namespace in current GitLab and nothing reproduces the array, so there is nothing to fix.

How was this tested?

Tests, lint and generated files

Each commit except 32 carries its own tests, written the way the file it touches is already tested: a mux.HandleFunc handler serving a fixture, from testdata where the package keeps them, that carries the keys the GitLab entity sends, with assertions on the decoded struct. The request fixes assert what is sent: the path before anything can redirect it for the Sidekiq routes, and the exact body through testBodyJSON for the omitempty fixes, which decodes the body into a map so a key sent as null fails the comparison; a companion test pins that a field the caller did set is still sent. The work item change holds its three documents against golden files in testdata.

On the head of the branch, with Go 1.27.1 in workspace mode:

  • go test -race ./... ./config/... passes, and so do ten shuffled runs of the root package.
  • golangci-lint run ./... ./config/... with golangci-lint 2.13.2, the version .tool-versions pins, reports 0 issues, and gofumpt -l reports nothing.
  • The three scripts behind make generate (generate_testing_client.sh, generate_service_interface_map.sh, generate_mock_api.sh) reproduce the committed files byte for byte once formatted. No .proto file changes, so buf has nothing to do here, and I did not run it.
  • Every commit also builds, passes go vet and passes go test ./... ./config/... on top of the ones before it, so the branch can be reviewed and bisected one commit at a time.

I did not run the integration tests under gitlab_test, which need a live instance. The fixtures are built from the entities at the GitLab tags cited in each commit. Five of the defects were first seen against a running GitLab: the feature flag 400, the package protection 422 and the work item documents Community Edition refuses by gitlab-mcp-server's end-to-end suite, and the epic label decode failure and the Orbit 406 on gitlab.com.

Closes #2300.

Edited by José M. Requena Plens

Merge request reports

Loading
Loading