diff --git a/CLAUDE.md b/CLAUDE.md index 165a2502d..ea0a9a31f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,7 +701,7 @@ go test ./internal/prompts/ -count=1 -v # Prompts ### Running E2E tests -The suite drives the **real `cmd/server` binary** over stdio against a real GitLab, so a tool call crosses a process boundary and then the network; read `test/e2e/README.md` before changing any of it. **The package decides the runtime, not a build tag**: `test/e2e/gitlab/common` for what any instance serves (176 test files), `ce` for what only an unlicensed one does (6), `ee` for Premium and Ultimate (49), all three compiled together under the one `e2e` tag. Every target stages the binary first, so none of them is a bare `go test`: +The suite drives the **real `cmd/server` binary** over stdio against a real GitLab, so a tool call crosses a process boundary and then the network; read `test/e2e/README.md` before changing any of it. **The package decides the runtime, not a build tag**: `test/e2e/gitlab/common` for what any instance serves (176 test files), `ce` for what only an unlicensed one does (7), `ee` for Premium and Ultimate (49), all three compiled together under the one `e2e` tag. Every target stages the binary first, so none of them is a bare `go test`: ```bash make test-e2e # self-hosted, from .env (GITLAB_URL, GITLAB_TOKEN); a package the instance cannot serve skips diff --git a/cmd/audit_1to1/internal/structs/analyze.go b/cmd/audit_1to1/internal/structs/analyze.go index bf71e3713..6ac9adaac 100644 --- a/cmd/audit_1to1/internal/structs/analyze.go +++ b/cmd/audit_1to1/internal/structs/analyze.go @@ -172,6 +172,13 @@ const ( // deploymentMergeRequestsInert is the reason three merge request list // options are absent from the deployment merge request input. deploymentMergeRequestsInert = "declared by GET /projects/:id/deployments/:deployment_id/merge_requests through merge_requests_base_params and read by nothing there: lib/api/deployments.rb presents MergeRequestBasic with current_user alone, and only serializer_options_for in lib/api/merge_requests.rb turns the option into a presenter option; exposed on the merge request list inputs, where it takes effect" + // notificationNewEpicNotProjects is the reason the shared notification + // events leave out new_epic, which client-go's options carry for all three + // update routes. + notificationNewEpicNotProjects = "declared for PUT /notification_settings and PUT /groups/:id/notification_settings and not for PUT /projects/:id/notification_settings (EE::NotificationSetting::EMAIL_EVENTS_MAPPING, ee/app/models/ee/notification_setting.rb:7-17 at v19.5.0-ee; gitlab-api-live.json, 19.5.0), so Grape drops it from a project's update on every tier; the account-wide and group inputs embed it as epicEvent and their handlers send it" + // projectChecksumFiltersUnread is the reason the project lists offer + // neither checksum filter, which client-go's options carry. + projectChecksumFiltersUnread = "declared by every route that takes the project list filters (optional_filter_params_ee, ee/lib/ee/api/helpers/projects_helpers.rb:38-42 at v19.5.0-ee) and read by nothing: the finder parameters the routes build carry neither (EE::API::Helpers#project_finder_params_ee, ee/lib/ee/api/helpers.rb:119-127), and no finder, model or service names them, so a list asked for the failed checksums answered every project on every tier" // skypeDiscarded is the reason the user create and modify inputs offer no // skype, which client-go's options still carry. skypeDiscarded = "GitLab discards it: the users.skype column is ignored since 18.4 and neither POST /users nor PUT /users/:id declares the param, so a caller who set it changed nothing" @@ -1114,6 +1121,18 @@ var acceptedMissingInputs = &declarationTable{name: "acceptedMissingInputs", ent // route declares the param. "users.CreateInput.job_title": jobTitleDiscarded, "users.ModifyInput.job_title": jobTitleDiscarded, + // An SDK options field one of the routes it is sent to does not declare. + // The three notification updates share one options struct, and GitLab + // declares new_epic for the account-wide and the group settings and not + // for a project's (EE::NotificationSetting::EMAIL_EVENTS_MAPPING), so the + // shared events the builder copies leave it out and the two inputs that + // take it embed it on their own. + "notifications.eventFields.new_epic": notificationNewEpicNotProjects, + // SDK options fields every route that takes them declares and none reads. + // Every project list goes through userProjectFilter, so the two keys + // answer all five lists at once. + "projects.userProjectFilter.repository_checksum_failed": projectChecksumFiltersUnread, + "projects.userProjectFilter.wiki_checksum_failed": projectChecksumFiltersUnread, // Params modeled on a nested object / slice element per the full-nested-object // policy (the auditor flattens the SDK nested options into the parent input). diff --git a/cmd/audit_edition_tier/param_declarations.go b/cmd/audit_edition_tier/param_declarations.go index 2eea81182..a2b5d2bfb 100644 --- a/cmd/audit_edition_tier/param_declarations.go +++ b/cmd/audit_edition_tier/param_declarations.go @@ -100,6 +100,17 @@ const ( // reasonDuoNeedsAPaidTierOrAnAddOn answers the two Duo settings of a group // update. reasonDuoNeedsAPaidTierOrAnAddOn = "GitLab stores the setting on every Enterprise Edition tier (ee/lib/ee/api/helpers/groups_helpers.rb:102-107 at v19.5.0-ee), and GitLab Duo, which it switches, answers where the group's licensed AI features are available: Premium carries ai_chat, Ultimate ai_features, and a Free namespace has them only with a GitLab Credits add-on (EE::Group#licensed_ai_features_available?, ee/app/models/ee/group.rb:368-372, and EE::Namespace#has_active_gitlab_credits_add_on? on the root ancestor, ee/app/models/ee/namespace.rb:336-348). The response carries the setting only there (ee/lib/ee/api/entities/group.rb:47-56). A tag would take it from a Free namespace with credits, so the description says so instead (issue 1240)." + // reasonMirrorRegexOnEveryTier answers the branch regex of a push mirror + // create and update. + reasonMirrorRegexOnEveryTier = "Enterprise Edition declares mirror_branch_regex on the push mirror create and update with no license check (EE::API::Helpers::RemoteMirrorsHelpers, ee/lib/ee/api/helpers/remote_mirrors_helpers.rb:11-14 at v19.5.0-ee), lets the services save it (EE::RemoteMirrors::Attributes, ee/app/models/ee/remote_mirrors/attributes.rb:8-24) and pushes only the branches it matches on every tier (EE::RemoteMirror#options_for_update and #branches_to_sync, ee/app/models/ee/remote_mirror.rb:22-35). Community Edition does not declare it (lib/api/helpers/remote_mirrors_helpers.rb:13-14) and drops it, as it drops every Enterprise Edition parameter, which the tier model cannot tell from Free (issue 1240)." + // reasonHiddenProjectsOnEveryTier answers the hidden project filter of the + // project list. + reasonHiddenProjectsOnEveryTier = "Enterprise Edition applies include_hidden for an administrator on every tier, with no license check: the list keeps it only for a caller who can administer all resources (EE::API::Projects#verify_project_filters!, ee/lib/ee/api/projects.rb:116-121 at v19.5.0-ee), hands it to the finder (EE::API::Helpers#project_finder_params_ee, ee/lib/ee/api/helpers.rb:119-127), and EE::ProjectsFinder#by_hidden returns the hidden projects only when it is set (ee/app/finders/ee/projects_finder.rb:49-51). Community Edition does not declare it, as it declares no Enterprise Edition parameter, and its finder hides no project (issue 1240)." + // reasonDuoCodeReviewWhereAvailable answers the automatic Duo code review + // of a project update. + reasonDuoCodeReviewWhereAvailable = "GitLab keeps the setting only where Duo code review is available to the project, which the row's Ultimate does not describe: the update deletes it unless the instance's license carries review_merge_request, a Premium feature (EE::API::Helpers::ProjectsHelpers#filter_attributes_using_license!, ee/lib/ee/api/helpers/projects_helpers.rb:144-149 at v19.5.0-ee, and ee/app/models/gitlab_subscriptions/features.rb:221), and the project has Duo code review, through the Duo Agent Platform's code review flow or a Duo Enterprise add-on (EE::Project#auto_duo_code_review_settings_available? and #duo_code_review_available?, ee/app/models/ee/project.rb:741-743 and 761-777). The platform is open to a Free group with a GitLab Credits add-on (Gitlab::Llm::StageCheck.available?, ee/lib/gitlab/llm/stage_check.rb:19-32), which on GitLab.com, whose license carries every feature, keeps the setting below Premium. A tag would take it from that group, so the description says where it applies (issue 1240)." + // reasonOneAssigneeBelowPremium answers the assignees of an issue create. + reasonOneAssigneeBelowPremium = "Every tier takes the parameter, and what needs Premium is a second assignee: IssuableBaseService#filter_assignees_using_checks keeps only the first ID unless the issue allows multiple assignees (app/services/issuable_base_service.rb:117-122 at v19.5.0-ee), which Community Edition never does (Issuable#allows_multiple_assignees?, app/models/concerns/issuable.rb:189-191) and Enterprise Edition does where multiple_issue_assignees is licensed (EE::Issue#allows_multiple_assignees?, ee/app/models/ee/issue.rb:195-197), a feature of the starter list every Premium license carries (ee/app/models/gitlab_subscriptions/features.rb:67 and 363). On GitLab.com, whose license carries every feature, the namespace's plan decides (EE::Project#load_licensed_feature_available, ee/app/models/ee/project.rb:1876-1886), so a Free namespace keeps the first as an unlicensed instance does, and the description says both (issue 1240)." ) // declaredParamAnswers holds the answer to every parameter finding that is @@ -145,6 +156,14 @@ var declaredParamAnswers = map[string]paramDeclaration{ "ci_variable.group_delete filter": {Documented: tierPremium, Category: categoryNotGated, Reason: reasonVariableFilterOnEveryTier}, "ci_variable.group_update environment_scope": {Documented: tierPremium, Category: categoryNotGated, Reason: reasonUpdateScopeIsTheFilter}, "ci_variable.group_create environment_scope": {Documented: tierPremium, Category: categoryValueLevelGate, Reason: reasonVariableScopeOnCreate}, + + "issue.create assignee_ids": {Documented: tierPremium, Category: categoryValueLevelGate, Reason: reasonOneAssigneeBelowPremium}, + + "project.mirror_add mirror_branch_regex": {Documented: tierPremium, Category: categoryNotGated, Reason: reasonMirrorRegexOnEveryTier}, + "project.mirror_edit mirror_branch_regex": {Documented: tierPremium, Category: categoryNotGated, Reason: reasonMirrorRegexOnEveryTier}, + + "project.list include_hidden": {Documented: tierPremium, Category: categoryNotGated, Reason: reasonHiddenProjectsOnEveryTier}, + "project.update auto_duo_code_review_enabled": {Documented: tierUltimate, Category: categoryValueLevelGate, Reason: reasonDuoCodeReviewWhereAvailable}, } // answeredParamFinding is a finding a declaration answers, with the answer. diff --git a/cmd/audit_edition_tier/param_declarations_test.go b/cmd/audit_edition_tier/param_declarations_test.go index 492c5f54e..5c67450b4 100644 --- a/cmd/audit_edition_tier/param_declarations_test.go +++ b/cmd/audit_edition_tier/param_declarations_test.go @@ -605,6 +605,73 @@ const ( routeGroupVariableDelete = "DELETE " + routeGroupVariable ) +// The assignee row of doc/api/issues.md, as GitLab's page carries it on master +// on 2026-10-27, and the route it documents. +const ( + rowIssueAssigneeIDs = "| `assignee_ids` | integer array | No | The IDs of the users to assign the issue to. Premium and Ultimate only.|" + routeIssueCreate = "POST /projects/:id/issues" +) + +// The approver row of doc/api/merge_requests.md, which the page writes the +// same way for its three lists, as GitLab's page carries it on master on +// 2026-10-27, and the routes it documents. +const ( + rowMergeRequestApproverIDs = "| `approver_ids[]` | integer array | No | Returns merge requests which have all users with the specified `id` as eligible approvers according to the approval rules. `None` returns merge requests with no eligible approvers. `Any` returns merge requests with at least one eligible approver. Premium and Ultimate only. |" + routeMergeRequestsGlobal = "GET /merge_requests" + routeMergeRequestsProject = "GET /projects/:id/merge_requests" + routeMergeRequestsGroup = "GET /groups/:id/merge_requests" +) + +// The new epic row of doc/api/notification_settings.md, which the page writes +// the same way for the account-wide update and for the update it documents +// for a group and a project in one table, as GitLab's page carries it on +// master on 2026-10-27, and the routes it documents. +const ( + rowNotificationNewEpic = "| `new_epic` | boolean | No | Turn on notifications when a new epic is created (in the Premium and Ultimate tier) |" + routeNotificationsGlobal = "PUT /notification_settings" + routeNotificationsGroup = "PUT /groups/:id/notification_settings" + routeNotificationsProject = "PUT /projects/:id/notification_settings" + routesNotificationsScoped = routeNotificationsGroup + "\n" + routeNotificationsProject +) + +// The branch regex rows of doc/api/remote_mirrors.md, as GitLab's page +// carries them on master on 2026-10-27, and the routes they document. +const ( + rowMirrorBranchRegexCreate = "| `mirror_branch_regex` | string | No | Regular expression for branch names to mirror. Only branches with names matching the regex are mirrored. Requires `only_protected_branches` to be disabled. Premium and Ultimate only. |" + rowMirrorBranchRegexUpdate = "| `mirror_branch_regex` | string | No | Regular expression for branch names to mirror. Only branches with names matching the regex are mirrored. Does not work with `only_protected_branches` enabled. Premium and Ultimate only. |" + routeRemoteMirrorCreate = "POST /projects/:id/remote_mirrors" + routeRemoteMirrorUpdate = "PUT /projects/:id/remote_mirrors/:mirror_id" +) + +// The rows of doc/api/projects.md that issue 1240 lists, as GitLab's page +// carries them on master on 2026-10-27, and the routes they document. The +// create, the create for a user and the update write the rows they share the +// same way. +const ( + rowProjectIncludeHidden = "| `include_hidden` | boolean | No | Include hidden projects. _(administrators only)_ Premium and Ultimate only. |" + rowProjectRepositoryChecksumFailed = "| `repository_checksum_failed` | boolean | No | Limit projects where the repository checksum calculation has failed. Premium and Ultimate only. |" + rowProjectWikiChecksumFailed = "| `wiki_checksum_failed` | boolean | No | Limit projects where the wiki checksum calculation has failed. Premium and Ultimate only. |" + rowProjectClassificationLabel = "| `external_authorization_classification_label` | string | No | The classification label for the project. Premium and Ultimate only. |" + rowProjectGroupWithTemplates = "| `group_with_project_templates_id` | integer | No | For group-level custom templates, specifies ID of group from which all the custom project templates are sourced. Leave empty for instance-level templates. Requires `use_custom_template` to be true. Premium and Ultimate only. |" + rowProjectMirrorTriggerBuilds = "| `mirror_trigger_builds` | boolean | No | Pull mirroring triggers builds. Premium and Ultimate only. |" + rowProjectMirror = "| `mirror` | boolean | No | Enables pull mirroring in a project. Premium and Ultimate only. |" + rowProjectTemplateProjectID = "| `template_project_id` | integer | No | When used with `use_custom_template`, project ID of a custom project template. Using a project ID is preferable to using `template_name` because `template_name` can be ambiguous. Premium and Ultimate only. |" + rowProjectUseCustomTemplate = "| `use_custom_template` | boolean | No | Use either custom [instance](../administration/project_templates.md) or [group](../user/group/custom_project_templates.md) (with `group_with_project_templates_id`) project template. Premium and Ultimate only. |" + rowProjectTriggerApprovesDeploy = "| `allow_pipeline_trigger_approve_deployment` | boolean | No | Set whether or not a pipeline triggerer is allowed to approve deployments. Premium and Ultimate only. |" + rowProjectAutoDuoCodeReview = "| `auto_duo_code_review_enabled` | boolean | No | Enable automatic reviews by GitLab Duo on merge requests. See [GitLab Duo in merge requests](../user/project/merge_requests/duo_in_merge_requests.md#use-gitlab-duo-to-review-your-code). Ultimate only. |" + rowProjectCancellationRole = "| `ci_restrict_pipeline_cancellation_role` | string | No | Set the [role required to cancel a pipeline or job](../ci/pipelines/settings.md#restrict-roles-that-can-cancel-pipelines-or-jobs). One of `developer`, `maintainer`, or `no_one`. Premium and Ultimate only. |" + rowProjectIssuesTemplate = "| `issues_template` | string | No | Default description for new issues. Formatted as GitLab Flavored Markdown. Premium and Ultimate only. |" + rowProjectMergeRequestsTemplate = "| `merge_requests_template` | string | No | Default description for new merge requests. Formatted as GitLab Flavored Markdown. Premium and Ultimate only. |" + rowProjectMirrorOverwrites = "| `mirror_overwrites_diverged_branches` | boolean | No | Pull mirror overwrites diverged branches. Premium and Ultimate only. |" + rowProjectMirrorUserID = "| `mirror_user_id` | integer | No | User responsible for all the activity surrounding a pull mirror event. _(administrators only)_ Premium and Ultimate only. |" + rowProjectOnlyMirrorProtected = "| `only_mirror_protected_branches` | boolean | No | Only mirror protected branches. Premium and Ultimate only. |" + rowProjectPreventMergeWithoutJira = "| `prevent_merge_without_jira_issue` | boolean | No | Set whether merge requests require an associated issue from Jira. Ultimate only. |" + routeProjectsList = "GET /projects" + routeProjectCreate = "POST /projects" + routeProjectCreateForUser = "POST /projects/user/:user_id" + routeProjectUpdate = "PUT /projects/:id" +) + // TestRunMain_RealTable_AnswersTheRowsGitLabsSourceSettles drives the entry // point over a module root whose record names the actions of one page and // whose cache holds that page's sections, for each page whose rows issue 1240 @@ -717,6 +784,85 @@ func TestRunMain_RealTable_AnswersTheRowsGitLabsSourceSettles(t *testing.T) { "ci_variable.group_delete filter": categoryNotGated, }, }, + { + name: "issues", + area: "issues", + domain: "issues", + page: endpointSection("New issue", routeIssueCreate, rowIssueAssigneeIDs), + record: []actionrequests.RecordAction{restRecord("issue.create", routeIssueCreate)}, + want: map[string]string{ + "issue.create assignee_ids": categoryValueLevelGate, + }, + }, + { + name: "merge_requests", + area: "merge_requests", + domain: "mergerequests", + page: endpointSection("List merge requests", routeMergeRequestsGlobal, rowMergeRequestApproverIDs) + + endpointSection("List project merge requests", routeMergeRequestsProject, rowMergeRequestApproverIDs) + + endpointSection("List group merge requests", routeMergeRequestsGroup, rowMergeRequestApproverIDs), + record: []actionrequests.RecordAction{ + restRecord("merge_request.list_global", routeMergeRequestsGlobal), + restRecord("merge_request.list", routeMergeRequestsProject), + restRecord("merge_request.list_group", routeMergeRequestsGroup), + }, + want: map[string]string{}, + }, + { + name: "notification_settings", + area: "notification_settings", + domain: "notifications", + page: endpointSection("Update global notification settings", routeNotificationsGlobal, rowNotificationNewEpic) + + endpointSection("Update group or project notification settings", routesNotificationsScoped, rowNotificationNewEpic), + record: []actionrequests.RecordAction{ + restRecord("user.notification_global_update", routeNotificationsGlobal), + restRecord("user.notification_group_update", routeNotificationsGroup), + restRecord("user.notification_project_update", routeNotificationsProject), + }, + want: map[string]string{}, + }, + { + name: "remote_mirrors", + area: "remote_mirrors", + domain: "projectmirrors", + page: endpointSection("Create a push mirror", routeRemoteMirrorCreate, rowMirrorBranchRegexCreate) + + endpointSection("Update a remote mirror's attributes", routeRemoteMirrorUpdate, rowMirrorBranchRegexUpdate), + record: []actionrequests.RecordAction{ + restRecord("project.mirror_add", routeRemoteMirrorCreate), + restRecord("project.mirror_edit", routeRemoteMirrorUpdate), + }, + want: map[string]string{ + "project.mirror_add mirror_branch_regex": categoryNotGated, + "project.mirror_edit mirror_branch_regex": categoryNotGated, + }, + }, + { + name: "projects", + area: "projects", + domain: "projects", + page: endpointSection("List all projects", routeProjectsList, + rowProjectIncludeHidden, rowProjectRepositoryChecksumFailed, rowProjectWikiChecksumFailed) + + endpointSection("Create a project", routeProjectCreate, + rowProjectClassificationLabel, rowProjectGroupWithTemplates, rowProjectMirrorTriggerBuilds, rowProjectMirror, + rowProjectTemplateProjectID, rowProjectUseCustomTemplate) + + endpointSection("Create a project for a user", routeProjectCreateForUser, + rowProjectClassificationLabel, rowProjectGroupWithTemplates, rowProjectMirrorTriggerBuilds, rowProjectMirror, + rowProjectUseCustomTemplate) + + endpointSection("Update a project", routeProjectUpdate, + rowProjectTriggerApprovesDeploy, rowProjectAutoDuoCodeReview, rowProjectCancellationRole, rowProjectClassificationLabel, + rowProjectIssuesTemplate, rowProjectMergeRequestsTemplate, rowProjectMirrorOverwrites, rowProjectMirrorTriggerBuilds, + rowProjectMirrorUserID, rowProjectMirror, rowProjectOnlyMirrorProtected, rowProjectPreventMergeWithoutJira), + record: []actionrequests.RecordAction{ + restRecord("project.list", routeProjectsList), + restRecord("project.create", routeProjectCreate), + restRecord("project.create_for_user", routeProjectCreateForUser), + restRecord("project.update", routeProjectUpdate), + }, + want: map[string]string{ + "project.list include_hidden": categoryNotGated, + "project.update auto_duo_code_review_enabled": categoryValueLevelGate, + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/docs/development/cmd-utilities.md b/docs/development/cmd-utilities.md index d6059084a..0e5bf8e64 100644 --- a/docs/development/cmd-utilities.md +++ b/docs/development/cmd-utilities.md @@ -758,7 +758,7 @@ What it cannot see: a row under a subheading of its endpoint (the options of a n #### How a finding is answered -Each lead is read against GitLab's source and answered in one of two places ([issue 1240](https://github.com/jmrplens/gitlab-mcp-server/issues/1240)). A `tier:"premium"` or `tier:"ultimate"` tag on the input field answers it in the schema: the tier filter prunes the parameter from the lower tiers, and the finding goes away by itself. Any other answer is a declaration in `cmd/audit_edition_tier/param_declarations.go`, keyed `action param`, naming the tier its row names, a category and a reason with the GitLab source line that settles it: +Each lead is read against GitLab's source and answered in one of two places ([issue 1240](https://github.com/jmrplens/gitlab-mcp-server/issues/1240)). A `tier:"premium"` or `tier:"ultimate"` tag on the input field answers it in the schema: the tier filter prunes the parameter from the lower tiers, and the finding goes away by itself. So does a parameter that no tier honors, because the route does not declare it (the `new_epic` flag of a project's notification settings, documented in one table with a group's) or declares it and reads it nowhere (the two checksum filters of the project lists): the input stops offering it on every tier, and since client-go's options still carry it, the struct audit's `acceptedMissingInputs` table in `cmd/audit_1to1/internal/structs/analyze.go` records why. Any other answer is a declaration in `cmd/audit_edition_tier/param_declarations.go`, keyed `action param`, naming the tier its row names, a category and a reason with the GitLab source line that settles it: | Category | Answer | What the schema is held to | | ------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -768,6 +768,8 @@ Each lead is read against GitLab's source and answered in one of two places ([is A declaration answers the findings of its action and parameter whose row names its tier, and is stale when it answers nothing. Half of that rule needs no page and is a gate: `TestDeclaredParamAnswers_TheRealTable_HoldsAgainstTheServedSchemas` fails the unit suite on a declaration with no reason, an unknown category, an action that does not offer the parameter below the row's tier (a tag that prunes it there answers the row instead) or a schema that no longer holds for its category, and the report lists the same problems. The other half, a declaration whose action's pages were all read and that answered no finding, is only reported, since it rests on GitLab's pages, which a gate would have to fetch; an action with a page that could not be read, its owner page or the override page it is graded against beside it, is not judged. A stale declaration answers nothing, so the finding it was written for stays open. +The rows a `not-gated` declaration or a removed parameter answers are a page that disagrees with GitLab's source, and they are recorded as documentation fixes to offer GitLab in [row 142 of the upstream register](upstream-bugs.md#the-api-pages-mark-parameters-for-a-paid-tier-that-every-tier-applies-or-none-does). + #### Make targets - `make audit-edition-tier` diff --git a/docs/development/repository-map.md b/docs/development/repository-map.md index 7fb8abfdf..c4e68d881 100644 --- a/docs/development/repository-map.md +++ b/docs/development/repository-map.md @@ -159,7 +159,7 @@ gitlab-mcp-server/ │ ├── internal/harness/ # The one route from a test to a server: the binary, the session, the fixtures, the ledger, the waits, the surfaces and the coverage shards. Carries `e2e`, so only the packages below can import it │ └── gitlab/ # The suite that drives the real binary against a real GitLab, one package per runtime │ ├── common/ # What any instance serves (176 test files) -│ ├── ce/ # What only an unlicensed instance does (6) +│ ├── ce/ # What only an unlicensed instance does (7) │ └── ee/ # Premium and Ultimate (49) ├── plan/ # Untracked working area for plans and audit reports: ignored by git and never committed. `make check-plan-untracked` (a CI step beside the em dash check) fails on a file force-added there ├── mcpb/ # Claude Desktop extension (.mcpb) manifest, icon, platform.jq (which derives the manifest of each per-OS bundle from the universal one) and linux/launch.sh, the Linux entry point that picks the amd64 or arm64 binary by uname -m (packed, measured and checked by scripts/build-mcpb.sh, one bundle per operating system plus the universal one; tests in scripts/mcpb_launch_sh_test.py for the launcher, scripts/mcpb_manifest_test.py for the manifest and its derivation and scripts/build_mcpb_sh_test.py for the build script's checks, size limits and the files it packs from both dist/ layouts) diff --git a/docs/development/upstream-bugs.md b/docs/development/upstream-bugs.md index 4ef58d714..89a7cba00 100644 --- a/docs/development/upstream-bugs.md +++ b/docs/development/upstream-bugs.md @@ -165,6 +165,7 @@ for the fork, branch, fix, test and MR workflow. - [Issue move and clone, and transfer, are authorized at the source boundary only for a fine-grained token](#issue-move-and-clone-and-transfer-are-authorized-at-the-source-boundary-only-for-a-fine-grained-token) - [The REST code search answers an exact-search name match with the path as its snippet](#the-rest-code-search-answers-an-exact-search-name-match-with-the-path-as-its-snippet) - [The custom role and broadcast message pages list a level the API refuses](#the-custom-role-and-broadcast-message-pages-list-a-level-the-api-refuses) + - [The API pages mark parameters for a paid tier that every tier applies, or none does](#the-api-pages-mark-parameters-for-a-paid-tier-that-every-tier-applies-or-none-does) - [GitLab Orbit (`gitlab-org/orbit/knowledge-graph`)](#gitlab-orbit-gitlab-orgorbitknowledge-graph) - [The DSL schema says a path query may omit `rel_types`](#the-dsl-schema-says-a-path-query-may-omit-rel_types) - [The DSL schema says the default neighbors direction is `both`](#the-dsl-schema-says-the-default-neighbors-direction-is-both) @@ -392,6 +393,7 @@ readable without opening the tracker: | 139 | client-go | [The protected environment approval rule options model an `access_level_description` no route declares](#the-protected-environment-approval-rule-options-model-an-access_level_description-no-route-declares) | No | No | No | No | Yes, the four project and group protect and update actions no longer offer it | | 140 | gobco | [gobco bridges a black box test package through an import it may not find](#gobco-bridges-a-black-box-test-package-through-an-import-it-may-not-find) | Yes, by another user, [rillig/gobco#33](https://github.com/rillig/gobco/issues/33) | Yes, by another contributor, [rillig/gobco#39](https://github.com/rillig/gobco/pull/39), open | No | No; the condition gate could not measure a package it reaches | Yes, temporarily: the staged copy of `scripts/coverage-conditions.sh` since 2026-10-27, by the decision of row 97, until a gobco release carries a fix | | 141 | gitlab-org/gitlab | [The custom role and broadcast message pages list a level the API refuses](#the-custom-role-and-broadcast-message-pages-list-a-level-the-api-refuses) | No | No | No | No; GitLab answers both with `400` | Yes, the two custom role creates offer Guest to Maintainer and the two broadcast message writes every target but Security Manager | +| 142 | gitlab-org/gitlab | [The API pages mark parameters for a paid tier that every tier applies, or none does](#the-api-pages-mark-parameters-for-a-paid-tier-that-every-tier-applies-or-none-does) | No; a documentation merge request is drafted, not sent | No | No | No; the schemas followed the pages | Yes ([issue 1240](https://github.com/jmrplens/gitlab-mcp-server/issues/1240)): each row is declared with the source that settles it, and a parameter no tier honors is no longer offered | States verified against the upstream trackers on 2026-09-12, and rows 8 to 23 again on 2026-09-13 when the go-sdk batch was filed. Rows 39 to 44 were added @@ -15566,6 +15568,71 @@ routes. correction, after the merge request of [row 110](#the-api-pages-list-access-levels-the-api-refuses) had merged. +### The API pages mark parameters for a paid tier that every tier applies, or none does + +- **Reported**: no. A documentation merge request on gitlab-org/gitlab is the + next step for the rows the source settles; it is drafted and not sent. The + rows where the code applies a parameter its page sells as a paid feature + are a question for GitLab before they are a merge request, since either the + page or the missing license check can be the defect. +- **In review**: no. +- **Merged**: no. Read at `v19.5.0-ee` and on master on 2026-10-27, where + every row below still carries its tier. +- **Blocking**: no. A tier marker on a parameter every tier applies hides it + from nobody who reads the code, and the two filters no tier reads were + answered with every project rather than refused. The cost is a schema that + followed the page. +- **Workaround**: yes, since + [issue 1240](https://github.com/jmrplens/gitlab-mcp-server/issues/1240): + each row is answered in `cmd/audit_edition_tier/param_declarations.go` + with the source line that settles it, or, for a parameter no tier honors, + removed from the input. Nothing upstream retires it: a corrected page only + stops disagreeing with the schemas. + +**Where and what**, read against GitLab's Grape params and services: + +- The source settles the row, and the page is what is wrong: + - `doc/api/groups.md`, `marked_for_deletion_on` on the group list: Community + Edition declares and applies it (`lib/api/groups.rb:37`, whose own + description repeats the tier, and `app/finders/groups_finder.rb:158-162`). + - `doc/api/group_level_variables.md`, `filter` on the get, update and + delete: read from the raw params on every tier and edition + (`lib/api/helpers/variables_helpers.rb:16-22` and + `app/finders/ci/variables_finder.rb:26-29`). + - `doc/api/notification_settings.md`, `new_epic` in the table the page + shares between the group and the project update: a project's update does + not declare it (`EE::NotificationSetting::EMAIL_EVENTS_MAPPING`, + `ee/app/models/ee/notification_setting.rb:7-17`), so the row is the + group's alone. + - `doc/api/projects.md`, `repository_checksum_failed` and + `wiki_checksum_failed` on the project lists: declared + (`ee/lib/ee/api/helpers/projects_helpers.rb:38-42`) and read by nothing, + so the filter returns every project on every tier. + - `doc/api/projects.md`, `auto_duo_code_review_enabled` on the update, + "Ultimate only": the update keeps it where the instance's license carries + `review_merge_request`, a Premium feature, and the project has Duo code + review (`ee/lib/ee/api/helpers/projects_helpers.rb:144-149`). +- Enterprise Edition applies the parameter on every tier with no license + check, while the feature page sells it as Premium: + - `doc/api/groups.md`, `membership_lock` on the create and update (only the + settings page checks `member_lock`), and both compute minute limits, + which an administrator sets on every tier + (`ee/app/services/ee/groups/update_service.rb:80-86`). + - `doc/api/remote_mirrors.md`, `mirror_branch_regex` on the push mirror + create and update (`ee/lib/ee/api/helpers/remote_mirrors_helpers.rb:11-14` + and `ee/app/models/ee/remote_mirror.rb:22-35`). + - `doc/api/projects.md`, `include_hidden` on the project list, kept for an + administrator (`ee/lib/ee/api/projects.rb:116-121`). + +**What it costs this server**: nothing at run time now. Its schemas followed +the pages, and since issue 1240 they follow the routes: the rows above are +declared, `new_epic` is offered only where it is declared, and the two +checksum filters are offered by no list. + +**How we found it**: answering the 65 parameter findings of +`make audit-edition-tier` for issue 1240, each read against the source at +v19.5.0-ee. + ## GitLab Orbit (`gitlab-org/orbit/knowledge-graph`) The first two entries here were filed as an issue first and then a merge diff --git a/internal/tools/action_catalog_test.go b/internal/tools/action_catalog_test.go index dfdb0bbb7..e1bd6965b 100644 --- a/internal/tools/action_catalog_test.go +++ b/internal/tools/action_catalog_test.go @@ -1238,6 +1238,151 @@ func TestCentralTierFilter_CustomMemberRoles_AreOfferedFromTheTierGitLabHonorsTh }) } +// TestCentralTierFilter_MergeRequestApproverFilter_IsOfferedFromPremium holds +// the eligible approver filter of the three merge request lists to Premium +// (issue 1240). Enterprise Edition applies it on every tier, but it matches +// approval rules, which a project gets only through the approval rule routes, +// refused without the Premium merge_request_approvers feature, so below +// Premium it finds the rules left from a licensed time, and None answers every +// merge request. The approved-by filters stay on Free, since approving is a +// Free feature. +func TestCentralTierFilter_MergeRequestApproverFilter_IsOfferedFromPremium(t *testing.T) { + assertInputsOfferedFrom(t, []tieredInput{ + {name: "list_approver_ids", id: "merge_request.list", field: "approver_ids", from: edition.Premium}, + {name: "list_global_approver_ids", id: "merge_request.list_global", field: "approver_ids", from: edition.Premium}, + {name: "list_group_approver_ids", id: "merge_request.list_group", field: "approver_ids", from: edition.Premium}, + {name: "list_approved_by_ids", id: "merge_request.list", field: "approved_by_ids", from: edition.Free}, + {name: "list_global_approved_by_usernames", id: "merge_request.list_global", field: "approved_by_usernames", from: edition.Free}, + }) +} + +// assertInputsOfferedOnNoTier builds the catalog of each tier and holds every +// input in cases absent from every schema that serves its action, which is +// what a parameter GitLab's route never reads must be. As in +// [assertInputsOfferedFrom], a case whose action the Ultimate catalog does +// not serve names no action and fails. +func assertInputsOfferedOnNoTier(t *testing.T, cases []tieredInput) { + t.Helper() + tiers := []edition.Tier{edition.Free, edition.Premium, edition.Ultimate} + catalogs := make(map[edition.Tier]*actioncatalog.Catalog, len(tiers)) + for _, tier := range tiers { + catalogs[tier] = mustBuildActionCatalog(t, nil, ActionCatalogOptions{Tier: tier, IncludeMCP: true}) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, served := catalogs[edition.Ultimate].Action(tc.id); !served { + t.Fatalf("%s is not an action of the Ultimate catalog", tc.id) + } + for _, tier := range tiers { + if _, served := catalogs[tier].Action(tc.id); served && actionHasInputProp(t, catalogs[tier], tc.id, tc.field) { + t.Errorf("%s %s input schema offers %q, which its route never reads", tier, tc.id, tc.field) + } + } + }) + } +} + +// TestCentralTierFilter_EpicNotification_IsOfferedWhereGitLabTakesIt holds the +// new epic notification flag to the scopes and the tier GitLab v19.5.0-ee +// takes it at (issue 1240). Enterprise Edition declares new_epic for the +// account-wide and the group settings and not for a project's +// (EE::NotificationSetting::EMAIL_EVENTS_MAPPING), so a project's schema +// offering it invited a flag Grape drops on every tier. Where it is declared +// it is stored on every tier, but an epic exists only from Premium, so it +// follows the Premium gate of the epic actions. The other event flags stay on +// every tier and every scope. +func TestCentralTierFilter_EpicNotification_IsOfferedWhereGitLabTakesIt(t *testing.T) { + assertInputsOfferedFrom(t, []tieredInput{ + {name: "global_new_epic", id: "user.notification_global_update", field: "new_epic", from: edition.Premium}, + {name: "group_new_epic", id: "user.notification_group_update", field: "new_epic", from: edition.Premium}, + {name: "group_new_issue", id: "user.notification_group_update", field: "new_issue", from: edition.Free}, + {name: "project_new_issue", id: "user.notification_project_update", field: "new_issue", from: edition.Free}, + }) + assertInputsOfferedOnNoTier(t, []tieredInput{ + {name: "project_new_epic", id: "user.notification_project_update", field: "new_epic"}, + }) +} + +// TestCentralTierFilter_ProjectSettings_AreOfferedFromTheTierGitLabHonorsThem +// holds the project parameters GitLab's API pages mark for a paid tier to the +// tier GitLab v19.5.0-ee honors them at (issue 1240). Each tagged one is +// dropped or refused below its tier: the classification label, the +// deployment approval by a pipeline trigger, the Jira issue requirement and +// the pipeline cancellation role are deleted unless the instance's license +// carries their feature (EE::API::Helpers::ProjectsHelpers#filter_attributes_using_license!); +// a custom template needs the instance's custom_project_templates, without +// which a template_project_id is refused; the issue and merge request +// description templates are deleted unless the project carries +// issuable_default_templates; and every pull mirror setting needs +// admin_mirror, which needs the repository_mirrors feature: a create applies +// mirror and mirror_trigger_builds only for a caller who has it +// (EE::Projects::CreateService#setup_pull_mirroring), and an update deletes +// every pull mirror attribute, the mirror's URL included, from a caller who +// has not (verify_mirror_attrs!). repository_mirrors is also a registration +// feature, which a Free self-managed Enterprise Edition instance has with +// Service Ping and registration features turned on +// (License.feature_available?), and the mirror settings take its license +// tier all the same, as the pull mirror actions beside them, the group wiki +// and the group webhooks already do. The built-in template name, the hidden +// project filter, which an administrator uses on every tier, and the +// automatic Duo code review stay on Free: the last follows the group Duo +// settings, which stay on every tier with a description, since a Free group +// with GitLab Credits keeps them on GitLab.com. +func TestCentralTierFilter_ProjectSettings_AreOfferedFromTheTierGitLabHonorsThem(t *testing.T) { + cases := []tieredInput{ + {name: "list_include_hidden", id: "project.list", field: "include_hidden", from: edition.Free}, + {name: "update_allow_pipeline_trigger_approve_deployment", id: "project.update", field: "allow_pipeline_trigger_approve_deployment", from: edition.Premium}, + {name: "update_ci_restrict_pipeline_cancellation_role", id: "project.update", field: "ci_restrict_pipeline_cancellation_role", from: edition.Premium}, + {name: "update_external_authorization_classification_label", id: "project.update", field: "external_authorization_classification_label", from: edition.Premium}, + {name: "update_issues_template", id: "project.update", field: "issues_template", from: edition.Premium}, + {name: "update_merge_requests_template", id: "project.update", field: "merge_requests_template", from: edition.Premium}, + {name: "update_import_url", id: "project.update", field: "import_url", from: edition.Premium}, + {name: "update_mirror", id: "project.update", field: "mirror", from: edition.Premium}, + {name: "update_mirror_trigger_builds", id: "project.update", field: "mirror_trigger_builds", from: edition.Premium}, + {name: "update_mirror_branch_regex", id: "project.update", field: "mirror_branch_regex", from: edition.Premium}, + {name: "update_mirror_overwrites_diverged_branches", id: "project.update", field: "mirror_overwrites_diverged_branches", from: edition.Premium}, + {name: "update_mirror_user_id", id: "project.update", field: "mirror_user_id", from: edition.Premium}, + {name: "update_only_mirror_protected_branches", id: "project.update", field: "only_mirror_protected_branches", from: edition.Premium}, + {name: "update_prevent_merge_without_jira_issue", id: "project.update", field: "prevent_merge_without_jira_issue", from: edition.Ultimate}, + {name: "update_auto_duo_code_review_enabled", id: "project.update", field: "auto_duo_code_review_enabled", from: edition.Free}, + } + // project.create_for_user embeds the create input, so both creates are + // held to the same tiers. + for _, id := range []actioncatalog.ActionID{"project.create", "project.create_for_user"} { + cases = append(cases, + tieredInput{name: string(id) + "_external_authorization_classification_label", id: id, field: "external_authorization_classification_label", from: edition.Premium}, + tieredInput{name: string(id) + "_use_custom_template", id: id, field: "use_custom_template", from: edition.Premium}, + tieredInput{name: string(id) + "_template_project_id", id: id, field: "template_project_id", from: edition.Premium}, + tieredInput{name: string(id) + "_group_with_project_templates_id", id: id, field: "group_with_project_templates_id", from: edition.Premium}, + tieredInput{name: string(id) + "_mirror", id: id, field: "mirror", from: edition.Premium}, + tieredInput{name: string(id) + "_mirror_trigger_builds", id: id, field: "mirror_trigger_builds", from: edition.Premium}, + tieredInput{name: string(id) + "_template_name", id: id, field: "template_name", from: edition.Free}, + ) + } + assertInputsOfferedFrom(t, cases) +} + +// TestCentralTierFilter_ProjectChecksumFilters_AreOfferedOnNoTier holds the +// repository and wiki checksum filters out of every project list's schema +// (issue 1240). GitLab's API page marks both Premium and Ultimate only, and +// Enterprise Edition declares them on every route that takes the project +// list filters (ee/lib/ee/api/helpers/projects_helpers.rb, at v19.5.0-ee), +// but nothing reads them: the finder parameters the routes build carry +// neither (EE::API::Helpers#project_finder_params_ee), so a list asked for +// the failed checksums answered every project on every tier. +func TestCentralTierFilter_ProjectChecksumFilters_AreOfferedOnNoTier(t *testing.T) { + var cases []tieredInput + for _, id := range []actioncatalog.ActionID{ + "project.list", "project.list_forks", "project.list_user_projects", "project.list_user_contributed", "project.list_user_starred", + } { + cases = append(cases, + tieredInput{name: string(id) + "_repository_checksum_failed", id: id, field: "repository_checksum_failed"}, + tieredInput{name: string(id) + "_wiki_checksum_failed", id: id, field: "wiki_checksum_failed"}, + ) + } + assertInputsOfferedOnNoTier(t, cases) +} + // TestCentralTierFilter_OutputSchemasArePrunedLenientlyPerTier is the output // half of the tier filter, across the three tiers built in one process. // diff --git a/internal/tools/issues/issues.go b/internal/tools/issues/issues.go index 66fe2c1bb..e59ff1bd1 100644 --- a/internal/tools/issues/issues.go +++ b/internal/tools/issues/issues.go @@ -35,7 +35,7 @@ type CreateInput struct { // Assignment and tracking AssigneeID int64 `json:"assignee_id,omitempty" jsonschema:"Single user ID to assign (use assignee_ids for multiple)"` - AssigneeIDs []int64 `json:"assignee_ids,omitempty" jsonschema:"User IDs to assign"` + AssigneeIDs []int64 `json:"assignee_ids,omitempty" jsonschema:"User IDs to assign. More than one needs Premium or Ultimate: below them, on an unlicensed instance or a Free GitLab.com namespace, GitLab keeps only the first"` Labels []string `json:"labels,omitempty" jsonschema:"Label names to apply"` MilestoneID int64 `json:"milestone_id,omitempty" jsonschema:"Milestone ID to associate. Mutually exclusive with milestone"` Milestone string `json:"milestone,omitempty" jsonschema:"Title of a project or ancestor-group milestone to associate, matched exactly (case-sensitive). GitLab leaves the issue without a milestone when no milestone has that title. Mutually exclusive with milestone_id"` @@ -204,7 +204,7 @@ type UpdateInput struct { Description *string `json:"description,omitempty" jsonschema:"New description (Markdown supported). Pass an empty string to remove the description. Omit the field to leave it untouched"` StateEvent string `json:"state_event,omitempty" jsonschema:"State transition (close, reopen)"` AssigneeID *int64 `json:"assignee_id,omitempty" jsonschema:"Single user ID to assign (use assignee_ids for multiple). Pass 0 to remove every assignee. Omit the field to leave them untouched"` - AssigneeIDs []int64 `json:"assignee_ids,omitempty" jsonschema:"User IDs that replace the issue's assignees. Pass [] to remove every assignee. Omit the field to leave them untouched"` + AssigneeIDs []int64 `json:"assignee_ids,omitempty" jsonschema:"User IDs that replace the issue's assignees. More than one needs Premium or Ultimate: below them, on an unlicensed instance or a Free GitLab.com namespace, GitLab keeps only the first. Pass [] to remove every assignee. Omit the field to leave them untouched"` Labels []string `json:"labels,omitempty" jsonschema:"Label names that replace all labels on the issue. Pass [] to remove every label. Omit the field to leave them untouched"` AddLabels []string `json:"add_labels,omitempty" jsonschema:"Label names to add without removing existing"` RemoveLabels []string `json:"remove_labels,omitempty" jsonschema:"Label names to remove"` diff --git a/internal/tools/mergerequests/merge_requests.go b/internal/tools/mergerequests/merge_requests.go index 63c240fef..8f956abad 100644 --- a/internal/tools/mergerequests/merge_requests.go +++ b/internal/tools/mergerequests/merge_requests.go @@ -118,7 +118,7 @@ type ListInput struct { AuthorID int64 `json:"author_id,omitempty" jsonschema:"Filter by author user ID"` AssigneeID toolutil.StringOrInt `json:"assignee_id,omitempty" jsonschema:"Filter by assignee: a user ID, None for merge requests nobody is assigned, or Any for those somebody is"` ReviewerID toolutil.StringOrInt `json:"reviewer_id,omitempty" jsonschema:"Filter by reviewer: a user ID, None for merge requests with no reviewer, or Any for those with one"` - ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` + ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" tier:"premium" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` ApprovedByIDs toolutil.ApproverIDsFilter `json:"approved_by_ids,omitempty" jsonschema:"Filter by MRs approved by all listed user IDs. Accepts user IDs, or exactly one of \"Any\" (approved by someone) or \"None\" (unapproved)"` ApprovedByUsernames []string `json:"approved_by_usernames,omitempty" jsonschema:"Filter by MRs approved by all listed usernames"` WithLabelsDetails *bool `json:"with_labels_details,omitempty" jsonschema:"Include full label details (color, description) in the response"` @@ -1348,7 +1348,7 @@ type ListGlobalInput struct { AuthorID int64 `json:"author_id,omitempty" jsonschema:"Filter by author user ID"` AssigneeID toolutil.StringOrInt `json:"assignee_id,omitempty" jsonschema:"Filter by assignee: a user ID, None for merge requests nobody is assigned, or Any for those somebody is"` ReviewerID toolutil.StringOrInt `json:"reviewer_id,omitempty" jsonschema:"Filter by reviewer: a user ID, None for merge requests with no reviewer, or Any for those with one"` - ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` + ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" tier:"premium" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` ApprovedByIDs toolutil.ApproverIDsFilter `json:"approved_by_ids,omitempty" jsonschema:"Filter by MRs approved by all listed user IDs. Accepts user IDs, or exactly one of \"Any\" (approved by someone) or \"None\" (unapproved)"` ApprovedByUsernames []string `json:"approved_by_usernames,omitempty" jsonschema:"Filter by MRs approved by all listed usernames"` WithLabelsDetails *bool `json:"with_labels_details,omitempty" jsonschema:"Include full label details (color, description) in the response"` @@ -1560,7 +1560,7 @@ type ListGroupInput struct { AuthorID int64 `json:"author_id,omitempty" jsonschema:"Filter by author user ID"` AssigneeID toolutil.StringOrInt `json:"assignee_id,omitempty" jsonschema:"Filter by assignee: a user ID, None for merge requests nobody is assigned, or Any for those somebody is"` ReviewerID toolutil.StringOrInt `json:"reviewer_id,omitempty" jsonschema:"Filter by reviewer: a user ID, None for merge requests with no reviewer, or Any for those with one"` - ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` + ApproverIDs toolutil.ApproverIDsFilter `json:"approver_ids,omitempty" tier:"premium" jsonschema:"Filter by MRs with all listed users as eligible approvers. Accepts user IDs, or exactly one of \"Any\" (has approvers) or \"None\" (has none)"` ApprovedByIDs toolutil.ApproverIDsFilter `json:"approved_by_ids,omitempty" jsonschema:"Filter by MRs approved by all listed user IDs. Accepts user IDs, or exactly one of \"Any\" (approved by someone) or \"None\" (unapproved)"` ApprovedByUsernames []string `json:"approved_by_usernames,omitempty" jsonschema:"Filter by MRs approved by all listed usernames"` WithLabelsDetails *bool `json:"with_labels_details,omitempty" jsonschema:"Include full label details (color, description) in the response"` diff --git a/internal/tools/notifications/notifications.go b/internal/tools/notifications/notifications.go index da02edee6..f5cf2509c 100644 --- a/internal/tools/notifications/notifications.go +++ b/internal/tools/notifications/notifications.go @@ -30,7 +30,9 @@ type GetGroupInput struct { GroupID toolutil.StringOrInt `json:"group_id" jsonschema:"Group ID or URL-encoded path,required"` } -// eventFields holds all notification event boolean flags shared across update inputs. +// eventFields holds the notification event boolean flags all three update +// inputs share. new_epic, which a project's settings do not take, is +// [epicEvent]. // // The Level description names no values on purpose: the account-wide settings // accept one level fewer than a project's or a group's, and one embedded struct @@ -49,7 +51,6 @@ type eventFields struct { MergeMergeRequest *bool `json:"merge_merge_request,omitempty" jsonschema:"Notify on MR merge"` MergeWhenPipelineSucceeds *bool `json:"merge_when_pipeline_succeeds,omitempty" jsonschema:"Notify on merge when pipeline succeeds"` MovedProject *bool `json:"moved_project,omitempty" jsonschema:"Notify on project move"` - NewEpic *bool `json:"new_epic,omitempty" jsonschema:"Notify on new epic"` NewIssue *bool `json:"new_issue,omitempty" jsonschema:"Notify on new issue"` NewMergeRequest *bool `json:"new_merge_request,omitempty" jsonschema:"Notify on new MR"` NewNote *bool `json:"new_note,omitempty" jsonschema:"Notify on new note"` @@ -61,9 +62,21 @@ type eventFields struct { SuccessPipeline *bool `json:"success_pipeline,omitempty" jsonschema:"Notify on pipeline success"` } +// epicEvent holds the one event flag the account-wide and the group settings +// take and a project's do not. Enterprise Edition declares new_epic for a +// group, where epics live, and for the account-wide settings a group falls +// back to, and not for a project, whose route Grape would drop it from +// (EE::NotificationSetting::EMAIL_EVENTS_MAPPING, at v19.5.0-ee). Where it is +// declared it is stored on every tier, but an epic exists only from Premium, +// so the flag is offered from Premium, as every epic action is (issue 1240). +type epicEvent struct { + NewEpic *bool `json:"new_epic,omitempty" tier:"premium" jsonschema:"Notify on new epic"` +} + // UpdateGlobalInput is the input for updating global notification settings. type UpdateGlobalInput struct { eventFields + epicEvent } // UpdateProjectInput is the input for updating project notification settings. @@ -76,6 +89,7 @@ type UpdateProjectInput struct { type UpdateGroupInput struct { GroupID toolutil.StringOrInt `json:"group_id" jsonschema:"Group ID or URL-encoded path,required"` eventFields + epicEvent } // Output type. @@ -162,6 +176,7 @@ func UpdateGlobalSettings(ctx context.Context, client *gitlabclient.Client, inpu if err != nil { return Output{}, err } + opts.NewEpic = input.NewEpic settings, _, err := client.GL().NotificationSettings.UpdateGlobalSettings(opts, gl.WithContext(ctx)) if err != nil { return Output{}, toolutil.WrapErrWithStatusHint("notification_global_update", err, http.StatusBadRequest, @@ -200,6 +215,7 @@ func UpdateSettingsForGroup(ctx context.Context, client *gitlabclient.Client, in if err != nil { return Output{}, err } + opts.NewEpic = input.NewEpic settings, _, err := client.GL().NotificationSettings.UpdateSettingsForGroup(string(input.GroupID), opts, gl.WithContext(ctx)) if err != nil { return Output{}, toolutil.WrapErrWithStatusHint("notification_group_update", err, http.StatusBadRequest, @@ -255,8 +271,10 @@ func levelHint(levels []string) string { // buildUpdateOpts assembles a [gl.NotificationSettingsOptions] from // the shared [eventFields], translating the Level string via -// [levelMap] and copying every event flag onto the options. levels names the -// values the caller's scope accepts, and a Level outside them is refused here. +// [levelMap] and copying every shared event flag onto the options; the two +// handlers whose scope takes [epicEvent] copy new_epic themselves. levels +// names the values the caller's scope accepts, and a Level outside them is +// refused here. // // Refusing matters more than it looks. An unrecognized level used to be dropped // on the floor: the PUT went out carrying every other field and no level, GitLab @@ -284,7 +302,6 @@ func buildUpdateOpts(e eventFields, levels []string) (*gl.NotificationSettingsOp opts.MergeMergeRequest = e.MergeMergeRequest opts.MergeWhenPipelineSucceeds = e.MergeWhenPipelineSucceeds opts.MovedProject = e.MovedProject - opts.NewEpic = e.NewEpic opts.NewIssue = e.NewIssue opts.NewMergeRequest = e.NewMergeRequest opts.NewNote = e.NewNote diff --git a/internal/tools/notifications/notifications_test.go b/internal/tools/notifications/notifications_test.go index 8472266df..ef795c534 100644 --- a/internal/tools/notifications/notifications_test.go +++ b/internal/tools/notifications/notifications_test.go @@ -353,7 +353,7 @@ func TestBuildUpdateOpts_AllBooleans(t *testing.T) { Level: "custom", NotificationEmail: "email@test.com", CloseIssue: &tr, CloseMergeRequest: &fa, FailedPipeline: &tr, FixedPipeline: &fa, IssueDue: &tr, MergeMergeRequest: &fa, MergeWhenPipelineSucceeds: &tr, MovedProject: &fa, - NewEpic: &tr, NewIssue: &fa, NewMergeRequest: &tr, NewNote: &fa, + NewIssue: &fa, NewMergeRequest: &tr, NewNote: &fa, PushToMergeRequest: &tr, ReassignIssue: &fa, ReassignMergeRequest: &tr, ReopenIssue: &fa, ReopenMergeRequest: &tr, SuccessPipeline: &fa, }, scopedLevels) @@ -600,34 +600,36 @@ func TestActionSpecs_CallRoutes(t *testing.T) { // request-option builder and the card. A line that names the wrong one of the // eighteen is a pure assignment, so neither the mutation nor the condition gate // can see it, and only a fixture that moves one event at a time can. That is -// why all three tests below share this table. +// why all three tests below share this table. send sets the event on an +// account-wide update, the one input that carries all eighteen, since a +// project's takes no new_epic. type eventFieldCase struct { key string label string set func(*EventOutput) - send func(*eventFields, *bool) + send func(*UpdateGlobalInput, *bool) } // eventFieldCases lists every event the notification settings carry. var eventFieldCases = []eventFieldCase{ - {"close_issue", "Close Issue", func(e *EventOutput) { e.CloseIssue = true }, func(f *eventFields, v *bool) { f.CloseIssue = v }}, - {"close_merge_request", "Close MR", func(e *EventOutput) { e.CloseMergeRequest = true }, func(f *eventFields, v *bool) { f.CloseMergeRequest = v }}, - {"failed_pipeline", "Failed Pipeline", func(e *EventOutput) { e.FailedPipeline = true }, func(f *eventFields, v *bool) { f.FailedPipeline = v }}, - {"fixed_pipeline", "Fixed Pipeline", func(e *EventOutput) { e.FixedPipeline = true }, func(f *eventFields, v *bool) { f.FixedPipeline = v }}, - {"issue_due", "Issue Due", func(e *EventOutput) { e.IssueDue = true }, func(f *eventFields, v *bool) { f.IssueDue = v }}, - {"merge_merge_request", "Merge MR", func(e *EventOutput) { e.MergeMergeRequest = true }, func(f *eventFields, v *bool) { f.MergeMergeRequest = v }}, - {"merge_when_pipeline_succeeds", "Merge When Pipeline Succeeds", func(e *EventOutput) { e.MergeWhenPipelineSucceeds = true }, func(f *eventFields, v *bool) { f.MergeWhenPipelineSucceeds = v }}, - {"moved_project", "Moved Project", func(e *EventOutput) { e.MovedProject = true }, func(f *eventFields, v *bool) { f.MovedProject = v }}, - {"new_issue", "New Issue", func(e *EventOutput) { e.NewIssue = true }, func(f *eventFields, v *bool) { f.NewIssue = v }}, - {"new_merge_request", "New MR", func(e *EventOutput) { e.NewMergeRequest = true }, func(f *eventFields, v *bool) { f.NewMergeRequest = v }}, - {"new_epic", "New Epic", func(e *EventOutput) { e.NewEpic = true }, func(f *eventFields, v *bool) { f.NewEpic = v }}, - {"new_note", "New Note", func(e *EventOutput) { e.NewNote = true }, func(f *eventFields, v *bool) { f.NewNote = v }}, - {"push_to_merge_request", "Push to MR", func(e *EventOutput) { e.PushToMergeRequest = true }, func(f *eventFields, v *bool) { f.PushToMergeRequest = v }}, - {"reassign_issue", "Reassign Issue", func(e *EventOutput) { e.ReassignIssue = true }, func(f *eventFields, v *bool) { f.ReassignIssue = v }}, - {"reassign_merge_request", "Reassign MR", func(e *EventOutput) { e.ReassignMergeRequest = true }, func(f *eventFields, v *bool) { f.ReassignMergeRequest = v }}, - {"reopen_issue", "Reopen Issue", func(e *EventOutput) { e.ReopenIssue = true }, func(f *eventFields, v *bool) { f.ReopenIssue = v }}, - {"reopen_merge_request", "Reopen MR", func(e *EventOutput) { e.ReopenMergeRequest = true }, func(f *eventFields, v *bool) { f.ReopenMergeRequest = v }}, - {"success_pipeline", "Success Pipeline", func(e *EventOutput) { e.SuccessPipeline = true }, func(f *eventFields, v *bool) { f.SuccessPipeline = v }}, + {"close_issue", "Close Issue", func(e *EventOutput) { e.CloseIssue = true }, func(f *UpdateGlobalInput, v *bool) { f.CloseIssue = v }}, + {"close_merge_request", "Close MR", func(e *EventOutput) { e.CloseMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.CloseMergeRequest = v }}, + {"failed_pipeline", "Failed Pipeline", func(e *EventOutput) { e.FailedPipeline = true }, func(f *UpdateGlobalInput, v *bool) { f.FailedPipeline = v }}, + {"fixed_pipeline", "Fixed Pipeline", func(e *EventOutput) { e.FixedPipeline = true }, func(f *UpdateGlobalInput, v *bool) { f.FixedPipeline = v }}, + {"issue_due", "Issue Due", func(e *EventOutput) { e.IssueDue = true }, func(f *UpdateGlobalInput, v *bool) { f.IssueDue = v }}, + {"merge_merge_request", "Merge MR", func(e *EventOutput) { e.MergeMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.MergeMergeRequest = v }}, + {"merge_when_pipeline_succeeds", "Merge When Pipeline Succeeds", func(e *EventOutput) { e.MergeWhenPipelineSucceeds = true }, func(f *UpdateGlobalInput, v *bool) { f.MergeWhenPipelineSucceeds = v }}, + {"moved_project", "Moved Project", func(e *EventOutput) { e.MovedProject = true }, func(f *UpdateGlobalInput, v *bool) { f.MovedProject = v }}, + {"new_issue", "New Issue", func(e *EventOutput) { e.NewIssue = true }, func(f *UpdateGlobalInput, v *bool) { f.NewIssue = v }}, + {"new_merge_request", "New MR", func(e *EventOutput) { e.NewMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.NewMergeRequest = v }}, + {"new_epic", "New Epic", func(e *EventOutput) { e.NewEpic = true }, func(f *UpdateGlobalInput, v *bool) { f.NewEpic = v }}, + {"new_note", "New Note", func(e *EventOutput) { e.NewNote = true }, func(f *UpdateGlobalInput, v *bool) { f.NewNote = v }}, + {"push_to_merge_request", "Push to MR", func(e *EventOutput) { e.PushToMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.PushToMergeRequest = v }}, + {"reassign_issue", "Reassign Issue", func(e *EventOutput) { e.ReassignIssue = true }, func(f *UpdateGlobalInput, v *bool) { f.ReassignIssue = v }}, + {"reassign_merge_request", "Reassign MR", func(e *EventOutput) { e.ReassignMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.ReassignMergeRequest = v }}, + {"reopen_issue", "Reopen Issue", func(e *EventOutput) { e.ReopenIssue = true }, func(f *UpdateGlobalInput, v *bool) { f.ReopenIssue = v }}, + {"reopen_merge_request", "Reopen MR", func(e *EventOutput) { e.ReopenMergeRequest = true }, func(f *UpdateGlobalInput, v *bool) { f.ReopenMergeRequest = v }}, + {"success_pipeline", "Success Pipeline", func(e *EventOutput) { e.SuccessPipeline = true }, func(f *UpdateGlobalInput, v *bool) { f.SuccessPipeline = v }}, } // TestGetGlobalSettings_OneEventAtATime_LandsInItsOwnOutputField verifies that @@ -674,7 +676,7 @@ func TestUpdateGlobalSettings_OneEventAtATime_SendsOnlyThatKey(t *testing.T) { sent := captureUpdateBody(t, func(client *gitlabclient.Client) error { off := false var input UpdateGlobalInput - tc.send(&input.eventFields, &off) + tc.send(&input, &off) _, err := UpdateGlobalSettings(t.Context(), client, input) return err }) @@ -716,6 +718,27 @@ func TestUpdateSettingsForGroup_Level_SendsTheSpellingGitLabAccepts(t *testing.T } } +// TestUpdateSettingsForGroup_NewEpic_SendsTheFlagAlone verifies that the group +// update sends the new epic flag the caller set, false included, and nothing +// beside it. The flag is the group's own rather than one of the shared events, +// since GitLab declares it for a group and not for a project, so the request +// option builder never sees it and a handler that forgot to copy it would +// drop it silently. +func TestUpdateSettingsForGroup_NewEpic_SendsTheFlagAlone(t *testing.T) { + off := false + sent := captureUpdateBody(t, func(client *gitlabclient.Client) error { + _, err := UpdateSettingsForGroup(t.Context(), client, UpdateGroupInput{GroupID: "grp", NewEpic: &off}) + return err + }) + + if len(sent) != 1 { + t.Fatalf("request body = %v, want only new_epic", sent) + } + if flag, ok := sent["new_epic"].(bool); !ok || flag { + t.Errorf("new_epic = %v, want the false the caller asked for", sent["new_epic"]) + } +} + // captureUpdateBody runs call against a client whose GitLab answers every // request with the shared settings fixture, and returns the JSON body the call // put on the wire. A handler cannot abort the test goroutine, so a read failure diff --git a/internal/tools/projects/projects.go b/internal/tools/projects/projects.go index 3345d44b3..d3ebec542 100644 --- a/internal/tools/projects/projects.go +++ b/internal/tools/projects/projects.go @@ -140,9 +140,9 @@ type CreateInput struct { // Template provisioning TemplateName string `json:"template_name,omitempty" jsonschema:"Built-in project template name to use"` - TemplateProjectID int64 `json:"template_project_id,omitempty" jsonschema:"Custom project template ID to use"` - UseCustomTemplate *bool `json:"use_custom_template,omitempty" jsonschema:"Use a custom group/instance project template"` - GroupWithProjectTemplatesID int64 `json:"group_with_project_templates_id,omitempty" jsonschema:"Group ID that provides the custom project templates"` + TemplateProjectID int64 `json:"template_project_id,omitempty" tier:"premium" jsonschema:"Custom project template ID to use"` + UseCustomTemplate *bool `json:"use_custom_template,omitempty" tier:"premium" jsonschema:"Use a custom group/instance project template"` + GroupWithProjectTemplatesID int64 `json:"group_with_project_templates_id,omitempty" tier:"premium" jsonschema:"Group ID that provides the custom project templates"` // Merge settings MergeMethod string `json:"merge_method,omitempty" jsonschema:"Merge method (merge, rebase_merge, ff)"` @@ -196,15 +196,15 @@ type CreateInput struct { PublicJobs *bool `json:"public_jobs,omitempty" jsonschema:"Enable public access to pipelines"` // Mirroring - Mirror *bool `json:"mirror,omitempty" jsonschema:"Enable pull mirroring from import_url"` - MirrorTriggerBuilds *bool `json:"mirror_trigger_builds,omitempty" jsonschema:"Trigger pipelines for mirror updates"` + Mirror *bool `json:"mirror,omitempty" tier:"premium" jsonschema:"Enable pull mirroring from import_url"` + MirrorTriggerBuilds *bool `json:"mirror_trigger_builds,omitempty" tier:"premium" jsonschema:"Trigger pipelines for mirror updates"` // Container registry cleanup ContainerExpirationPolicyAttributes *ContainerExpirationPolicyInput `json:"container_expiration_policy_attributes,omitempty" jsonschema:"Container registry cleanup policy attributes"` // Compliance and security EnforceAuthChecksOnUploads *bool `json:"enforce_auth_checks_on_uploads,omitempty" jsonschema:"Enforce authorization checks on uploads"` - ExternalAuthorizationClassificationLabel string `json:"external_authorization_classification_label,omitempty" jsonschema:"External authorization classification label"` + ExternalAuthorizationClassificationLabel string `json:"external_authorization_classification_label,omitempty" tier:"premium" jsonschema:"External authorization classification label"` // Access control RequestAccessEnabled *bool `json:"request_access_enabled,omitempty" jsonschema:"Allow users to request access"` @@ -485,18 +485,16 @@ type ListInput struct { Statistics *bool `json:"statistics,omitempty" jsonschema:"Include project statistics in response"` WithProgrammingLanguage string `json:"with_programming_language,omitempty" jsonschema:"Filter by programming language name"` IncludePendingDelete *bool `json:"include_pending_delete,omitempty" jsonschema:"Include projects that are marked/scheduled for deletion. Default false."` - IncludeHidden *bool `json:"include_hidden,omitempty" jsonschema:"Include hidden projects in results"` + IncludeHidden *bool `json:"include_hidden,omitempty" jsonschema:"Include hidden projects in results (administrators only)"` IDAfter int64 `json:"id_after,omitempty" jsonschema:"Return projects with ID greater than this value (keyset pagination)"` IDBefore int64 `json:"id_before,omitempty" jsonschema:"Return projects with ID less than this value (keyset pagination)"` // Additional filters (additive 1:1 SDK parity with ListProjectsOptions) - Active *bool `json:"active,omitempty" jsonschema:"Limit to active (non-archived, non-pending-deletion) projects"` - Imported *bool `json:"imported,omitempty" jsonschema:"Limit to projects imported from an external source by the current user"` - RepositoryChecksumFailed *bool `json:"repository_checksum_failed,omitempty" jsonschema:"Limit to projects where the repository checksum calculation failed (admin only)"` - WikiChecksumFailed *bool `json:"wiki_checksum_failed,omitempty" jsonschema:"Limit to projects where the wiki checksum calculation failed (admin only)"` - RepositoryStorage string `json:"repository_storage,omitempty" jsonschema:"Limit to projects on the given repository storage shard (admin only)"` - WithCustomAttributes *bool `json:"with_custom_attributes,omitempty" jsonschema:"Include custom attributes in the response (admin only)"` - CustomAttributes map[string]string `json:"custom_attributes,omitempty" jsonschema:"Filter projects by custom attribute key/value pairs (admin only). Different from with_custom_attributes, which only controls whether attributes are returned"` + Active *bool `json:"active,omitempty" jsonschema:"Limit to active (non-archived, non-pending-deletion) projects"` + Imported *bool `json:"imported,omitempty" jsonschema:"Limit to projects imported from an external source by the current user"` + RepositoryStorage string `json:"repository_storage,omitempty" jsonschema:"Limit to projects on the given repository storage shard (admin only)"` + WithCustomAttributes *bool `json:"with_custom_attributes,omitempty" jsonschema:"Include custom attributes in the response (admin only)"` + CustomAttributes map[string]string `json:"custom_attributes,omitempty" jsonschema:"Filter projects by custom attribute key/value pairs (admin only). Different from with_custom_attributes, which only controls whether attributes are returned"` toolutil.PaginationInput toolutil.KeysetPaginationInput @@ -581,22 +579,22 @@ type UpdateInput struct { // Basic metadata (additive 1:1 SDK parity) Path string `json:"path,omitempty" jsonschema:"New project path slug"` - ImportURL string `json:"import_url,omitempty" jsonschema:"URL to import/mirror repository from"` + ImportURL string `json:"import_url,omitempty" tier:"premium" jsonschema:"URL to import/mirror repository from"` RepositoryStorage string `json:"repository_storage,omitempty" jsonschema:"Repository storage shard name (admin only)"` TagList []string `json:"tag_list,omitempty" jsonschema:"Project tags (deprecated: use topics). Pass [] to remove every tag. Omit the field to leave them untouched"` // Merge settings (additive) OnlyAllowMergeIfAllStatusChecksPassed *bool `json:"only_allow_merge_if_all_status_checks_passed,omitempty" tier:"ultimate" jsonschema:"Only allow merge when all external status checks pass"` - AllowPipelineTriggerApproveDeployment *bool `json:"allow_pipeline_trigger_approve_deployment,omitempty" jsonschema:"Allow pipeline triggerer to approve deployment"` - PreventMergeWithoutJiraIssue *bool `json:"prevent_merge_without_jira_issue,omitempty" jsonschema:"Require an associated Jira issue before merge"` + AllowPipelineTriggerApproveDeployment *bool `json:"allow_pipeline_trigger_approve_deployment,omitempty" tier:"premium" jsonschema:"Allow pipeline triggerer to approve deployment"` + PreventMergeWithoutJiraIssue *bool `json:"prevent_merge_without_jira_issue,omitempty" tier:"ultimate" jsonschema:"Require an associated Jira issue before merge"` MergeRequestDefaultTargetSelf *bool `json:"mr_default_target_self,omitempty" jsonschema:"Default merge request target to the project itself (for forks)"` MergeTrainsSkipTrainAllowed *bool `json:"merge_trains_skip_train_allowed,omitempty" jsonschema:"Allow skipping the merge train"` MergeTrainEnforcement string `json:"merge_train_enforcement,omitempty" tier:"premium" jsonschema:"Who may merge a merge request around the merge train: allow_bypass, enforce_for_all_users or enforce_with_owner_override. It has no effect unless merge trains are enabled"` PrintingMergeRequestLinkEnabled *bool `json:"printing_merge_request_link_enabled,omitempty" jsonschema:"Show the create-merge-request link after pushing"` SuggestionCommitMessage *string `json:"suggestion_commit_message,omitempty" jsonschema:"Default commit message for suggestions. Pass an empty string to go back to the default message. Omit the field to leave it untouched"` IssueBranchTemplate *string `json:"issue_branch_template,omitempty" jsonschema:"Template for branch names created from issues. Pass an empty string to go back to the default template. Omit the field to leave it untouched"` - IssuesTemplate string `json:"issues_template,omitempty" jsonschema:"Default issue description template"` - MergeRequestsTemplate string `json:"merge_requests_template,omitempty" jsonschema:"Default merge request description template"` + IssuesTemplate string `json:"issues_template,omitempty" tier:"premium" jsonschema:"Default issue description template"` + MergeRequestsTemplate string `json:"merge_requests_template,omitempty" tier:"premium" jsonschema:"Default merge request description template"` // Feature toggles (additive) SnippetsEnabled *bool `json:"snippets_enabled,omitempty" jsonschema:"Enable snippets feature (deprecated: use snippets_access_level)"` @@ -613,7 +611,7 @@ type UpdateInput struct { // Auto DevOps (additive) AutoDevopsEnabled *bool `json:"auto_devops_enabled,omitempty" jsonschema:"Enable Auto DevOps for the project"` AutoDevopsDeployStrategy string `json:"auto_devops_deploy_strategy,omitempty" jsonschema:"Auto DevOps deploy strategy (continuous, manual, timed_incremental)"` - AutoDuoCodeReviewEnabled *bool `json:"auto_duo_code_review_enabled,omitempty" jsonschema:"Enable automatic GitLab Duo code review"` + AutoDuoCodeReviewEnabled *bool `json:"auto_duo_code_review_enabled,omitempty" jsonschema:"Enable automatic GitLab Duo code review. GitLab keeps it only where Duo code review is available to the project: on Premium or Ultimate with GitLab Duo, or on GitLab.com for a Free group with GitLab Credits. Elsewhere it is dropped"` // CI/CD settings (additive) BuildGitStrategy string `json:"build_git_strategy,omitempty" jsonschema:"Git strategy for builds (fetch, clone)"` @@ -627,26 +625,26 @@ type UpdateInput struct { CIPushRepositoryForJobTokenAllowed *bool `json:"ci_push_repository_for_job_token_allowed,omitempty" jsonschema:"Allow CI job tokens to push to the repository"` CIIDTokenSubClaimComponents []string `json:"ci_id_token_sub_claim_components,omitempty" jsonschema:"Components used to build the CI ID token sub claim"` CISeparatedCaches *bool `json:"ci_separated_caches,omitempty" jsonschema:"Use separate caches for protected branches"` - CIRestrictPipelineCancellationRole string `json:"ci_restrict_pipeline_cancellation_role,omitempty" jsonschema:"Role required to cancel pipelines/jobs (developer, maintainer, no_one)"` + CIRestrictPipelineCancellationRole string `json:"ci_restrict_pipeline_cancellation_role,omitempty" tier:"premium" jsonschema:"Role required to cancel pipelines/jobs (developer, maintainer, no_one)"` CIPipelineVariablesMinimumOverrideRole string `json:"ci_pipeline_variables_minimum_override_role,omitempty" jsonschema:"Minimum role allowed to override pipeline variables (developer, maintainer, owner, no_one_allowed)"` RestrictUserDefinedVariables *bool `json:"restrict_user_defined_variables,omitempty" jsonschema:"Restrict user-defined pipeline variables (deprecated: use ci_pipeline_variables_minimum_override_role)"` ResourceGroupDefaultProcessMode string `json:"resource_group_default_process_mode,omitempty" jsonschema:"Default resource group process mode (unordered, oldest_first, newest_first)"` PublicJobs *bool `json:"public_jobs,omitempty" jsonschema:"Enable public access to pipelines"` // Mirroring (additive) - Mirror *bool `json:"mirror,omitempty" jsonschema:"Enable pull mirroring from import_url"` - MirrorTriggerBuilds *bool `json:"mirror_trigger_builds,omitempty" jsonschema:"Trigger pipelines for mirror updates"` - MirrorBranchRegex string `json:"mirror_branch_regex,omitempty" jsonschema:"Only mirror branches matching this regex"` - MirrorOverwritesDivergedBranches *bool `json:"mirror_overwrites_diverged_branches,omitempty" jsonschema:"Overwrite diverged branches when mirroring"` - MirrorUserID *int64 `json:"mirror_user_id,omitempty" jsonschema:"User ID used to run mirror updates (admin only)"` - OnlyMirrorProtectedBranches *bool `json:"only_mirror_protected_branches,omitempty" jsonschema:"Only mirror protected branches"` + Mirror *bool `json:"mirror,omitempty" tier:"premium" jsonschema:"Enable pull mirroring from import_url"` + MirrorTriggerBuilds *bool `json:"mirror_trigger_builds,omitempty" tier:"premium" jsonschema:"Trigger pipelines for mirror updates"` + MirrorBranchRegex string `json:"mirror_branch_regex,omitempty" tier:"premium" jsonschema:"Only mirror branches matching this regex"` + MirrorOverwritesDivergedBranches *bool `json:"mirror_overwrites_diverged_branches,omitempty" tier:"premium" jsonschema:"Overwrite diverged branches when mirroring"` + MirrorUserID *int64 `json:"mirror_user_id,omitempty" tier:"premium" jsonschema:"User ID used to run mirror updates (admin only)"` + OnlyMirrorProtectedBranches *bool `json:"only_mirror_protected_branches,omitempty" tier:"premium" jsonschema:"Only mirror protected branches"` // Container registry cleanup (additive) ContainerExpirationPolicyAttributes *ContainerExpirationPolicyInput `json:"container_expiration_policy_attributes,omitempty" jsonschema:"Container registry cleanup policy attributes"` // Compliance and security (additive) EnforceAuthChecksOnUploads *bool `json:"enforce_auth_checks_on_uploads,omitempty" jsonschema:"Enforce authorization checks on uploads"` - ExternalAuthorizationClassificationLabel *string `json:"external_authorization_classification_label,omitempty" jsonschema:"External authorization classification label. Pass an empty string to remove it. Omit the field to leave it untouched"` + ExternalAuthorizationClassificationLabel *string `json:"external_authorization_classification_label,omitempty" tier:"premium" jsonschema:"External authorization classification label. Pass an empty string to remove it. Omit the field to leave it untouched"` // Access control (additive) IssuesAccessLevel string `json:"issues_access_level,omitempty" jsonschema:"Issues access level (disabled, private, enabled)"` @@ -1278,7 +1276,6 @@ func (input ListInput) toFilter() userProjectFilter { LastActivityAfter: input.LastActivityAfter, LastActivityBefore: input.LastActivityBefore, IDAfter: input.IDAfter, IDBefore: input.IDBefore, IncludePendingDelete: input.IncludePendingDelete, IncludeHidden: input.IncludeHidden, Active: input.Active, Imported: input.Imported, - RepositoryChecksumFailed: input.RepositoryChecksumFailed, WikiChecksumFailed: input.WikiChecksumFailed, RepositoryStorage: input.RepositoryStorage, WithCustomAttributes: input.WithCustomAttributes, CustomAttributes: input.CustomAttributes, PaginationInput: input.PaginationInput, KeysetPaginationInput: input.KeysetPaginationInput, @@ -1998,8 +1995,6 @@ type ListForksInput struct { IDBefore int64 `json:"id_before,omitempty" jsonschema:"Return forks with ID less than this value (keyset pagination)"` Active *bool `json:"active,omitempty" jsonschema:"Limit to active (non-archived, non-pending-deletion) forks"` Imported *bool `json:"imported,omitempty" jsonschema:"Limit to forks imported from an external source by the current user"` - RepositoryChecksumFailed *bool `json:"repository_checksum_failed,omitempty" jsonschema:"Limit to forks where the repository checksum calculation failed (admin only)"` - WikiChecksumFailed *bool `json:"wiki_checksum_failed,omitempty" jsonschema:"Limit to forks where the wiki checksum calculation failed (admin only)"` RepositoryStorage string `json:"repository_storage,omitempty" jsonschema:"Limit to forks on the given repository storage shard (admin only)"` WithCustomAttributes *bool `json:"with_custom_attributes,omitempty" jsonschema:"Include custom attributes in the response (admin only)"` CustomAttributes map[string]string `json:"custom_attributes,omitempty" jsonschema:"Filter projects by custom attribute key/value pairs (admin only). Different from with_custom_attributes, which only controls whether attributes are returned"` @@ -2039,7 +2034,6 @@ func (input ListForksInput) toFilter() userProjectFilter { LastActivityAfter: input.LastActivityAfter, LastActivityBefore: input.LastActivityBefore, IDAfter: input.IDAfter, IDBefore: input.IDBefore, IncludePendingDelete: input.IncludePendingDelete, IncludeHidden: input.IncludeHidden, Active: input.Active, Imported: input.Imported, - RepositoryChecksumFailed: input.RepositoryChecksumFailed, WikiChecksumFailed: input.WikiChecksumFailed, RepositoryStorage: input.RepositoryStorage, WithCustomAttributes: input.WithCustomAttributes, CustomAttributes: input.CustomAttributes, PaginationInput: input.PaginationInput, KeysetPaginationInput: input.KeysetPaginationInput, @@ -3141,8 +3135,14 @@ func ListUserStarredProjects(ctx context.Context, client *gitlabclient.Client, i // userProjectFilterInput is the embeddable, JSON-schema-bearing filter set // shared by the user-scoped project list tools (owned, contributed, starred). -// All three endpoints accept the full ListProjectsOptions filter set, so they -// surface the complete filter surface for 1:1 SDK parity. +// The owned and starred routes take the full ListProjectsOptions filter set, +// which it surfaces for 1:1 SDK parity, but for the repository and wiki +// checksum filters: Enterprise Edition declares both and no finder reads +// either (EE::API::Helpers#project_finder_params_ee, at v19.5.0-ee), so they +// are offered by no project list (issue 1240). The contributed route takes +// only order_by, sort and simple of them: it declares those, user_id and the +// pagination alone (lib/api/projects.rb, at v19.5.0-ee), and Grape drops +// the rest there. type userProjectFilterInput struct { Search string `json:"search,omitempty" jsonschema:"Search query for project name"` Visibility string `json:"visibility,omitempty" jsonschema:"Filter by visibility (private, internal, public)"` @@ -3168,8 +3168,6 @@ type userProjectFilterInput struct { IncludeHidden *bool `json:"include_hidden,omitempty" jsonschema:"Include hidden projects in results"` Active *bool `json:"active,omitempty" jsonschema:"Limit to active (non-archived, non-pending-deletion) projects"` Imported *bool `json:"imported,omitempty" jsonschema:"Limit to projects imported from an external source by the current user"` - RepositoryChecksumFailed *bool `json:"repository_checksum_failed,omitempty" jsonschema:"Limit to projects where the repository checksum calculation failed (admin only)"` - WikiChecksumFailed *bool `json:"wiki_checksum_failed,omitempty" jsonschema:"Limit to projects where the wiki checksum calculation failed (admin only)"` RepositoryStorage string `json:"repository_storage,omitempty" jsonschema:"Limit to projects on the given repository storage shard (admin only)"` WithCustomAttributes *bool `json:"with_custom_attributes,omitempty" jsonschema:"Include custom attributes in the response (admin only)"` CustomAttributes map[string]string `json:"custom_attributes,omitempty" jsonschema:"Filter projects by custom attribute key/value pairs (admin only). Different from with_custom_attributes, which only controls whether attributes are returned"` @@ -3186,7 +3184,8 @@ func (f userProjectFilterInput) toFilter() userProjectFilter { } // userProjectFilter holds the resolved filter parameters for user-scoped -// project listings, mirroring the full ListProjectsOptions filter set. +// project listings, mirroring the ListProjectsOptions filter set but for the +// two checksum filters no GitLab finder reads (see [userProjectFilterInput]). // // The json tags mirror the SDK url tags on gl.ListProjectsOptions so the // struct-completeness auditor (R-INPUT) can confirm 1:1 parity against the SDK @@ -3219,8 +3218,6 @@ type userProjectFilter struct { IncludeHidden *bool `json:"include_hidden,omitempty"` Active *bool `json:"active,omitempty"` Imported *bool `json:"imported,omitempty"` - RepositoryChecksumFailed *bool `json:"repository_checksum_failed,omitempty"` - WikiChecksumFailed *bool `json:"wiki_checksum_failed,omitempty"` RepositoryStorage string `json:"repository_storage,omitempty"` WithCustomAttributes *bool `json:"with_custom_attributes,omitempty"` CustomAttributes map[string]string `json:"custom_attributes,omitempty"` @@ -3316,12 +3313,6 @@ func applyUserProjectFilterPtrs(opts *gl.ListProjectsOptions, f userProjectFilte if f.Imported != nil { opts.Imported = f.Imported } - if f.RepositoryChecksumFailed != nil { - opts.RepositoryChecksumFailed = f.RepositoryChecksumFailed - } - if f.WikiChecksumFailed != nil { - opts.WikiChecksumFailed = f.WikiChecksumFailed - } if f.RepositoryStorage != "" { opts.RepositoryStorage = new(f.RepositoryStorage) } diff --git a/internal/tools/projects/projects_test.go b/internal/tools/projects/projects_test.go index 3cb06df73..91cf05e35 100644 --- a/internal/tools/projects/projects_test.go +++ b/internal/tools/projects/projects_test.go @@ -5442,8 +5442,6 @@ func TestBuildListOpts_AllBranches(t *testing.T) { IDBefore: 500, Active: new(true), Imported: new(true), - RepositoryChecksumFailed: new(true), - WikiChecksumFailed: new(true), RepositoryStorage: "nfs-01", WithCustomAttributes: new(true), @@ -5471,12 +5469,7 @@ func assertListProjectAdminOpts(t *testing.T, opts *gl.ListProjectsOptions) { if opts.Imported == nil || !*opts.Imported { t.Error("Imported not set") } - if opts.RepositoryChecksumFailed == nil || !*opts.RepositoryChecksumFailed { - t.Error("RepositoryChecksumFailed not set") - } - if opts.WikiChecksumFailed == nil || !*opts.WikiChecksumFailed { - t.Error("WikiChecksumFailed not set") - } + assertNoChecksumFilters(t, opts) if opts.RepositoryStorage == nil || *opts.RepositoryStorage != "nfs-01" { t.Error("RepositoryStorage not set") } @@ -8912,8 +8905,6 @@ func TestBuildForkListOpts_AllBranches(t *testing.T) { IDBefore: 500, Active: new(true), Imported: new(true), - RepositoryChecksumFailed: new(true), - WikiChecksumFailed: new(true), RepositoryStorage: "nfs-01", WithCustomAttributes: new(true), @@ -8951,13 +8942,24 @@ func assertAllListProjectFiltersSet(t *testing.T, opts *gl.ListProjectsOptions) opts.IDAfter != nil, opts.IDBefore != nil, } admin := []bool{ - opts.Active != nil, opts.Imported != nil, opts.RepositoryChecksumFailed != nil, - opts.WikiChecksumFailed != nil, opts.RepositoryStorage != nil, + opts.Active != nil, opts.Imported != nil, opts.RepositoryStorage != nil, opts.WithCustomAttributes != nil, } assertAllTrue(t, "scalar filters", scalars) assertAllTrue(t, "pointer filters", pointers) assertAllTrue(t, "admin filters", admin) + assertNoChecksumFilters(t, opts) +} + +// assertNoChecksumFilters fails the test if the options carry either checksum +// filter. Enterprise Edition declares both and no finder reads them, so a +// list built with every filter a caller can set still sends neither (issue +// 1240). +func assertNoChecksumFilters(t *testing.T, opts *gl.ListProjectsOptions) { + t.Helper() + if opts.RepositoryChecksumFailed != nil || opts.WikiChecksumFailed != nil { + t.Errorf("checksum filters = %v, %v, want neither sent", opts.RepositoryChecksumFailed, opts.WikiChecksumFailed) + } } // assertAllTrue fails the test if any flag in the group is false. @@ -9019,8 +9021,6 @@ func TestBuildUserProjectOpts_FullFilterSet(t *testing.T) { IncludeHidden: new(true), Active: new(true), Imported: new(true), - RepositoryChecksumFailed: new(true), - WikiChecksumFailed: new(true), RepositoryStorage: "nfs-02", WithCustomAttributes: new(true), diff --git a/test/e2e/gitlab/ce/issues_test.go b/test/e2e/gitlab/ce/issues_test.go new file mode 100644 index 000000000..b325b2ec5 --- /dev/null +++ b/test/e2e/gitlab/ce/issues_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 José M. Requena Plens +// SPDX-License-Identifier: MIT + +//go:build e2e + +// issues_test.go covers the one part of an issue create a license changes: +// how many assignees GitLab keeps, which below Premium is the first of those +// the caller names. + +package ce + +import ( + "slices" + "testing" + + gl "gitlab.com/gitlab-org/api/client-go/v3" + + "github.com/jmrplens/gitlab-mcp-server/v3/internal/tools/issues" + "github.com/jmrplens/gitlab-mcp-server/v3/test/e2e/internal/fixture" + "github.com/jmrplens/gitlab-mcp-server/v3/test/e2e/internal/harness" +) + +// actionIssueCreate is the issue create the scenario drives. +const actionIssueCreate harness.ActionID = "issue.create" + +// assigneePair is a project with two members an issue can be assigned to. +type assigneePair struct { + project fixture.Project + first fixture.User + second fixture.User +} + +// TestIssueCreate_TwoAssignees_OnTheFreeTier_KeepsOnlyTheFirst creates an issue +// assigned to two project members on every surface and holds the answer to +// the first of them (issue 1240). GitLab's API page marks assignee_ids +// Premium and Ultimate only, but every tier takes the parameter: what needs +// the license is a second assignee, which IssuableBaseService drops unless +// the issue allows several, so the schema offers it on Free and its +// description says that below Premium, on an unlicensed instance such as +// this one or a Free GitLab.com namespace, GitLab keeps the first. +func TestIssueCreate_TwoAssignees_OnTheFreeTier_KeepsOnlyTheFirst(t *testing.T) { + e := harness.New(t, harness.Needs(harness.NeedAdmin)) + + harness.SurfacesWith(e, buildAssigneePair, func(e *harness.Env, surface harness.Surface, pair assigneePair) { + created := harness.Do[issues.Output](e.On(surface), actionIssueCreate, map[string]any{ + "project_id": pair.project.IDParam(), + "title": e.Name("pair"), + "assignee_ids": []int64{pair.first.ID, pair.second.ID}, + }) + got := make([]int64, 0, len(created.Assignees)) + for _, assignee := range created.Assignees { + got = append(got, assignee.ID) + } + if want := []int64{pair.first.ID}; !slices.Equal(got, want) { + e.T.Errorf("issue #%d was created with assignees %v, want only the first named, %v", created.IID, got, want) + } + }) +} + +// buildAssigneePair creates the project and its two members once on the +// parent Env, since GitLab assigns an issue only to a user who can read it. +func buildAssigneePair(e *harness.Env) assigneePair { + e.T.Helper() + + pair := assigneePair{ + project: fixture.NewProject(e, fixture.WithNamePrefix("assignees")), + first: fixture.NewUser(e, "first"), + second: fixture.NewUser(e, "second"), + } + fixture.AddProjectMember(e, pair.project, pair.first, gl.DeveloperPermissions) + fixture.AddProjectMember(e, pair.project, pair.second, gl.DeveloperPermissions) + return pair +}