feat(plan_limits): add the limits and history GitLab sends and accepts

What does this MR do?

This is one of the small merge requests that carry the content of !3063 (closed) again, one topic each, now that it is closed (#2300 (closed) was its plan), and it answers a request on gitlab-org/terraform-provider-gitlab!3364: the acceptance test there raises the default plan's ci_pipeline_schedules limit through the plan limits API, and builds those requests by hand because PlanLimit does not decode that limit and ChangePlanLimitOptions cannot send it. Like the rest of that work, it comes from maintaining gitlab-mcp-server, an MCP server built on this library, whose plan limits tools read the limits this library drops from the raw response and cannot change them at all.

Both plan limit routes answer with every limit of a plan and its limits_history, which the entity exposes with no condition (plan_limit.rb lines 6-41, GitLab 19.5.1-ee), and PUT /application/plan_limits declares the same 29 limits as optional parameters (plan_limits.rb lines 58-105). PlanLimit and ChangePlanLimitOptions model eight of them, the maximum file sizes of eight package formats, so the other 21 are dropped from every answer and cannot be sent: ci_instance_level_variables, ci_pipeline_size, ci_active_jobs, ci_project_subscriptions, ci_pipeline_schedules, ci_needs_size_limit, ci_registered_group_runners, ci_registered_project_runners, dotenv_size, dotenv_variables, cargo_max_file_size, enforcement_limit, notification_limit, max_pipelines_per_merge_train, pipeline_hierarchy_size, service_desk_outbound_emails_per_hour, service_desk_outbound_emails_per_day, storage_size_limit, web_hook_calls, web_hook_calls_low and web_hook_calls_mid.

This MR adds the 21 to both structs, int64 fields on PlanLimit and pointers on ChangePlanLimitOptions, so a limit set to 0, which several of them read as no limit, is still sent. They follow the order in which Update plan limits lists the parameters, with web_hook_calls_low and web_hook_calls_mid after web_hook_calls, where the route has them. That table documents 19 of the 21; those two are missing from the page, and the route describes both as GitLab.com only, which the doc comments of both structs say. Both are in the OpenAPI document GitLab publishes, doc/api/openapi/openapi_v3.yaml, under putApiV4ApplicationPlanLimitsRequest and APIEntitiesPlanLimit, beside the 27 the page lists.

It also adds LimitsHistory, the changes GitLab records to four of the limits, which the page's example bodies leave out and the OpenAPI document types as an object. GitLab validates it against plan_limits_history.json, which allows four keys and no other (additionalProperties: false), and appends an entry with user_id, username, timestamp and value whenever an update changes one of those four limits (plan_limits.rb lines 11-18 and lines 72-89). Since the keys are a closed set, it is a struct, PlanLimitsHistory, with one []PlanLimitHistoryEntry per key, rather than a map.

In the same file, the doc comments of GetCurrentPlanLimits and ChangePlanLimits move to PlanLimitsServiceInterface, as in the files already migrated (merge_requests.go in !3140 (merged)), and the four GitLab API docs links that name a heading point at the page's current headings, Retrieve current plan limits and Update plan limits: the anchors they used, #get-current-plan-limits and #change-plan-limits, no longer exist. The doc comment of PlanLimit called it a pipeline, and that of PlanLimitsService spoke of repositories; both now describe plan limits.

Is this a breaking change?

No. The MR only adds fields and two types: no existing field, type, tag or signature changes. PlanLimit gains slices through PlanLimitsHistory, so it is no longer comparable with ==; on !3063 (closed) I asked about exactly this for PlanLimit, and the answer was that the library does not count comparability as part of its compatibility.

How was this tested?

  • plan_limits_test.go: TestGetCurrentPlanLimits decodes testdata/get_plan_limits.json, which carries every key the entity exposes, each limit with a value of its own and one history entry or more under each of the four keys, and checks the whole struct. TestChangePlanLimits sets every option, ci_pipeline_size to 0, checks that the JSON body carries each of them and nothing else, and decodes the answer. TestChangePlanLimits_SendsOnlyTheLimitsSet changes the pipeline schedules limit alone, as the provider test will, and checks that the body holds plan_name and ci_pipeline_schedules and nothing else. On main the new tests do not compile, since the fields do not exist. I checked that each fails for the defect it guards: a misspelled JSON tag on a history key or on a response limit fails both decoding tests, a misspelled tag on an option fails TestChangePlanLimits (and, on ci_pipeline_schedules, TestChangePlanLimits_SendsOnlyTheLimitsSet as well), and dropping omitempty from an option that TestChangePlanLimits_SendsOnlyTheLimitsSet leaves unset fails that test.
  • Two integration tests in gitlab_test/plan_limits_integration_test.go change a limit of the default plan on a live instance and restore it when they finish. Test_PlanLimitsChangePlanLimits_PipelineSchedules_Integration raises the pipeline schedules limit alone and reads it back; Test_PlanLimitsChangePlanLimits_LimitsHistory_Integration changes the notification limit and checks that the answer's history of that limit ends with the change, made by the user of the token. I ran them with go test ./gitlab_test/... -race -tags=integration -run '^Test_PlanLimits' against a container set up as the gitlab-ee-no-license service of docker-compose.yml sets it up, running GitLab 19.5.1-ee without a license: both pass, twice in a row, and after each run the two limits read as before, while the history keeps the changes, as GitLab records them. On main they do not compile, like the unit tests.
  • The steps of make reviewable with the versions .tool-versions pins (gofumpt 0.8.0, buf 1.55.1, golangci-lint 2.15.0) on Go 1.27.3: go mod tidy, make generate and make fmt change no file, golangci-lint reports 0 issues, buf format --exit-code and buf lint pass, and go vet is clean. go test -race work passes on Go 1.27.3 and Go 1.26.10. With the integration build tag, go vet is clean, gofumpt lists nothing, and golangci-lint reports nothing in the new file.

gitlab-mcp-server keeps a record of what it finds and contributes in the projects it depends on, and in sibling projects, in upstream-bugs.md.

Related to #2300 (closed)

Merge request reports

Loading
Loading