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.
- A nil options pointer (commit 2). A nil
*Optionsheld in ananyis notnil, soNewRequestToURLsends the four bytesnullas the body of every POST, PUT or PATCH given one. No one can know whether a caller relies on thenull, so commit 2 does not change the default: it adds the client optionWithNilOptionsOmitted(), 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. - The eight security attribute and category mutations return
*GraphQLResponseError(commit 4) for a refusal GitLab answers with HTTP 200 and a top-levelerrorsarray. Before, five of them returned success and three returnedErrNotFound, so a caller matchingErrNotFoundon those three sees a different error. Deployment,NoteandPlanLimitstop being comparable with==(commits 12, 16 and 31), because each gains a slice or a map GitLab sends.gorelease -base=v3.14.0reports it. Comparability is not something this library treats as part of its compatibility, so the fields stay.- 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 passesWorkItemDefaultListFields(). 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.
- fix(application_statistics): decode the counts GitLab sends as strings. The entity renders every count through
number_with_delimiter, soGET /application/statisticsanswers"issues": "1,234"(with the separator of the caller's language) and theint64fields could not decode it. AnUnmarshalJSONnow accepts a number or such a string. application_statistics.rb - feat(client): add option to treat nil options pointers as no options. A nil
*Optionsheld in ananyis notnil, soNewRequestToURLsends the four bytesnullas the body of a POST, PUT or PATCH, and replaces a query already on the URL for any other method;CancelJobandPublishAllDraftNotesreach it on their own through theirWithOptionssiblings.WithNilOptionsOmitted()makes such a pointer mean no options. It is off by default, so no request changes unless a caller opts in. Go FAQ - 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 - 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
errorsarray and a null payload, and the methods reported that as success or as a bareErrNotFound. They now return a*GraphQLResponseError, as the work item methods do. authorize_resource.rb - fix(orbit): send the schema format as response_format. The route reads
response_format, and Grape refusesformat=rawandformat=llmwith 406 before the route runs, soGetOrbitSchemacould only ever get the default answer.OrbitSchemaalso gains theformatted_textthellmformat answers with. orbit/data.rb - feat: add the Dependency Firewall operation and enablement endpoint.
EvaluatePackageOptionsgains the optionaloperationparam, andGetDependencyFirewallEnablementwrapsGET /projects/:id/dependency_firewall/enablement. enablement.rb - feat(achievements): add namespace, user objects and AwardMessageHTML. The fragments selected the namespace and the three users as
{ id }alone and never selectedawardMessageHtml. The IDs stay and the objects join them. user_achievement_type.rb - 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
- fix(group_relations_export): decode a relation status, add object count. With
relationset the status route answers one object, whichListExportStatusfailed to decode as a list; it now accepts both, andGroupRelationStatusgainsTotalObjectsCount. group_export.rb - 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
ReturnedFieldsthe listing already has, with the old selection as the default. features_type.rb - fix(epics): decode label details and add the fields GitLab sends. With
with_labels_detailsthe labels arrive as objects and every listing failed to decode.Epicnow keeps the names inLabelsand the objects inLabelDetails, asIssueandMergeRequestdo, and gains the keys the entity sends; the list options gainauthor_usernameandconfidential. epic.rb - feat(notes): model suggestions, quick action changes and thread state.
Notegainscommands_changesandsuggestions(the only way to read the ID a suggestion is applied by) among others, andDiscussiongainsresolvableandresolved. note.rb - feat(issue_links): add the issue fields a listed relation carries. The links route renders a whole issue plus the link keys, and
IssueRelationmodelled little more than a basic issue. (#2300) related_issue.rb - feat(merge_requests): add the HTML fields and blocked_merge_request.
RenderHTMLhad nothing to land in, andMergeRequestDependencycarried the blocking half of a dependency and not the blocked one. (#2300) merge_request_dependency.rb - 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
- feat(deployments): return the approval GitLab records for a deployment.
ApproveOrRejectProjectDeploymentV2returns the approval the route answers with, andDeploymentgains the approval fields the Enterprise Edition adds. deployments.rb - feat: add fields GitLab sends that eight response structs miss.
Key,SSHKey,Runner,RunnerDetails,ProjectLintResult(thejobsits owninclude_jobsoption asks for),Label,PipelineandPipelineTrigger. (#2300) ci/lint/result.rb - feat(merge_requests): return the whole pipeline a merge request runs. The create route answers with
Ci::Pipeline, whichPipelineInfodecodes minus twelve keys.CreateMergeRequestPipelineV2returns*Pipelineand takes the route'sasyncoption. (#2300) merge_requests.rb - feat: add the enum values GitLab accepts and a cancellation role type.
EventTypeValue,EventTargetTypeValue,TodoActionandDeploymentStatusValuelacked values GitLab validates against, andCIRestrictPipelineCancellationRolewas typed with the feature access levels. todo.rb - feat(projects): return the project a fork relationship is created on. The route answers with the project, which was decoded into a
ProjectForkRelationshape no entity exposes.CreateProjectForkRelationV2returns*Project. projects.rb - feat(projects): return the link a project shared with a group gets.
ShareProjectWithGroupdiscarded theProjectGroupLinkthe route answers 201 with, andShareProjectWithGroupV2returns it. project_group_link.rb - feat: add fields GitLab sends that Group, Project and Issue miss. Also
ProjectUser,ProjectApprovalRuleandGroupHook. Each conditional field's comment says when GitLab sends it. (#2300) group_detail.rb - feat: add five response keys and three parameters from GitLab 19.4.
duo_flow_callback_enabledon hooks and their options,ci_skip_branch_pipelines_for_mrson projects,provisioned_by_project_idon users andci_minutes_usageon namespaces. project_hook.rb - 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 - 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
- feat: add the token fields GitLab sends that the token structs miss.
granular,granular_scopesandlast_used_ipson personal access tokens, and what the impersonation and resource access token entities add to them. personal_access_token.rb - feat(users): add the fifteen user keys GitLab sends that User misses. Also
CreateServiceAccountUserV2, sincePOST /service_accountsanswers with a service account rather than a user. (#2300) user.rb - 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 - feat: add keys GitLab sends on every object that seven structs miss.
PackagePipeline,BasicUser,BillableGroupMember,ServiceAccount,ProjectServiceAccount,PendingInviteandAccessRequest;LicenseTemplate.Featured, which no entity sends, is deprecated. (#2300) access_requester.rb - feat(members): add what GitLab sends, accepts and serves for members. The member fields, the
skip_users,stateandinvite_sourceparams,DeleteProjectMemberWithOptions, and seven Enterprise member routes no method reached. members.rb - feat(plan_limits): add the limits and history GitLab sends and accepts.
PlanLimitandChangePlanLimitOptionsmodelled eight of the twenty-nine limits. plan_limit.rb - docs: deprecate the struct fields that no GitLab entity sends. Each decodes to its zero value on every call, and each now has a
Deprecatednote saying why, and what to read instead where GitLab sends the value under another name, for example the eventtitleanddatathatEntities::Eventsends astarget_titleandpush_data. event.rb - 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 - 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
- fix(protected_packages): omit unset fields from a rule update. A partial update sent the pattern and the type as
nulland was refused with 422. project_packages_protection_rules.rb - 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
nulloverwrites the stored value. pipeline_schedules.rb - fix(projects): make the pipeline cancellation role a type of its own. From the review:
CIRestrictPipelineCancellationRoleValuestops being an alias ofAccessControlValue, none of whose constants the setting accepts, so code that holds the role in anAccessControlValueno 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 asProjectForkRelation),ShareProjectWithGroupandApproveOrRejectProjectDeployment(a link and an approval, both discarded),CreateMergeRequestPipeline(a whole pipeline, decoded asPipelineInfo),CreateServiceAccountUser(a service account, decoded asUser) andRepairGeoSite(a site status, decoded asGeoSite). Each has aV2sibling with the right return type and is marked deprecated in its favour. In 4.0 the old name takes theV2signature, theV2goes, and so doesProjectForkRelation. - A nil options pointer still sends
nullby 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:
GetWorkItemandDeleteProjectMember. Each has aWithOptionssibling, the patternCancelJobandPublishAllDraftNotesalready follow, until 4.0 folds the options in. - Two fields are typed narrower than what GitLab sends:
GeoSiteStatus.Namespacesis a[]stringforNamespaceBasicobjects, andEpic.Labelsa[]stringfor the label objectswith_labels_detailsreturns. Both keep their type: anUnmarshalJSONkeeps the names there and puts the objects in a new field beside them (NamespaceDetails,LabelDetails). For labels this is the shapeIssueandMergeRequestalready 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
Emailof commit 12,Epic.UserNotesCountandLicenseTemplate.Featured. Removing them is the 4.0 half. - Some structs decode more than one entity.
Group,ProjectandIssueeach decode a narrow Grape entity and one or more wider ones that inherit it (forIssue,IssueBasicwhere other APIs render an issue andIssueon the issues API's own routes), andUserdecodes 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. GetMergeRequestChangesandChangeApprovalConfigurationreturn*MergeRequestfor endpoints that answer withMergeRequestChangesandApprovalState. 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
Hookfields, which !3048 (merged) added in v3.15.0. DetailedStatusstill lacks theactionobject and thesize,titleandcontentof the illustration that GitLab's DetailedStatusEntity renders. It needs a type of its own, so I kept it out of commit 18.GetNamespaceanswering with an array, which the record also lists:GET /namespaces/:idpresents oneEntities::Namespacein 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-versionspins, reports 0 issues, andgofumpt -lreports 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.protofile changes, sobufhas nothing to do here, and I did not run it. - Every commit also builds, passes
go vetand passesgo 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.