Report unresolvable reviewer usernames in setReviewers
mergeRequestSetReviewers returned errors: [] for a username that doesn't resolve to a visible user, and left the merge request untouched, so a caller couldn't tell that apart from a real assignment. It now resolves what it can, applies it, and reports the rest in errors rather than rejecting the whole call. An earlier revision of this MR did reject the call outright, but that would have broken the reviewer sidebar for any merge request with a blocked, banned or LDAP-blocked reviewer already assigned.
Fixes #611232 (closed)
Detail
Why it was broken
UsersFinder.new(current_user, username: usernames).executesilently drops usernames that match no visible user.- With
operationMode: APPEND, the resulting id set then equals the merge request's existing reviewers. MergeRequests::UpdateReviewersService#execute(app/services/merge_requests/update_reviewers_service.rb:15) sees no change and returns early, so nothing is written.errors: []comes back, indistinguishable from a real assignment.
The change
- Resolve the usernames once, diff against what came back, and add the unresolvable ones to
errors. - The usernames that did resolve are still applied. The call is not rejected as a whole.
- Comparison is case-insensitive, matching
User.by_username'siwhere(app/models/user.rb:728), soJDoestill resolves tojdoe. - Duplicate usernames are reported once, matched case-insensitively, since route paths are unique case-insensitively (
app/models/route.rb:27) soFooandfoocannot be two different users. - An empty username list still clears all reviewers, unchanged.
- Reused the existing
errorsarray rather than adding a payload field.field :errors(app/graphql/mutations/base_mutation.rb:15) already declaresscopes: [:api, :read_api, :ai_workflows], so anai_workflows-scoped caller can read it, whereasTypes::MergeRequestType#reviewers(app/graphql/types/merge_request_type.rb:247) defaults toscopes: [:api, :read_api]and can't be read back by that caller.
Why it applies partially instead of rejecting the call
- An earlier revision of this change rejected the whole call when any username was unresolvable. That's a breaking change under GitLab's definition: "Any change counts as a breaking change if customers need to take action to ensure their GitLab workflows aren't disrupted" (
doc/development/deprecation_guidelines/_index.md:12). - Concrete case: the reviewer sidebar sends the whole selected reviewer list in REPLACE mode (
app/assets/javascripts/merge_requests/components/reviewers/reviewer_dropdown.vue:341). For a non-admin,UsersFinder's base scope isUser.without_forbidden_states(app/models/user.rb:781), whereFORBIDDEN_SEARCH_STATESisblocked,banned,ldap_blocked(app/models/user.rb:75). On a merge request that already has a reviewer in one of those states, for example someone who left the company and isldap_blocked, every reviewer change from the sidebar would have been refused. The dropdown doesn't readerrors, so the user would have seen the change silently revert. - Verified against a local GDK as a non-admin Developer. With the rejecting version,
REPLACE ["evia", "codeowner"]whereeviais blocked returnedReviewers not found: eviaand left the reviewers as["evia"]. With partial application it returns the same error and the reviewers become["codeowner"]. - Partial application is what the GraphQL style guide prescribes.
errors"may be populated on success" (doc/development/api_graphql_styleguide.md:2028), and it gives a near identical example: "if a user uploads 10 files and 3 of them fail and the rest succeed, the errors for the failures can be made available to the user, alongside the information about the successes" (doc/development/api_graphql_styleguide.md:2094). - Precedent for partial application is
DesignManagementUpload(app/graphql/mutations/design_management/upload.rb:20), which applies what it can and reports the rest. The all-or-nothing precedents (ee/app/graphql/mutations/incident_management/oncall_rotation/base.rb:79andee/app/graphql/mutations/incident_management/escalation_policy/base.rb:81) raise a top-level argument error, and they exist where partial state is incoherent, such as half an escalation policy. Reviewer assignment is set membership, where partial application is coherent.
Behaviour change to weigh
- A call that previously returned
errors: []now returns the unresolvable usernames inerrors. The same reviewers are assigned as before, so no caller that succeeds today starts failing. - REPLACE with only unresolvable usernames still clears all reviewers, which is unchanged, but now says why.
- The reviewer sidebar doesn't read
errors, so there's no UI impact.
Who this affects
Any GraphQL API caller. The motivating case is the Duo Agent Platform recommend_reviewers flow, which calls this mutation through a new add_merge_request_reviewers tool (gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!6040 (merged)). Because failure was unreportable, that tool could only echo back the usernames it was asked to add. It can now report what was actually assigned.
Related, not fixed here
- Without the
multiple_merge_request_reviewerslicensed feature,reviewer_idstruncates with.first(1)(the EE override atee/app/services/ee/merge_requests/update_reviewers_service.rb:10skips truncation only when licensed). With APPEND, existing reviewers sort first, so the truncation drops the one being added, another silent no-change. Noted in the issue. Mutations::Assignable(app/graphql/mutations/concerns/mutations/assignable.rb:42) has the same silent-drop shape forassignee_usernames. It backsMergeRequestSetAssigneesandIssueSetAssignees.
Testing
- Added to
spec/graphql/mutations/merge_requests/set_reviewers_spec.rbandspec/requests/api/graphql/mutations/merge_requests/set_reviewers_spec.rb: an unresolvable username is reported while the ones that resolved are still assigned; an existing reviewer in a stateUsersFinderexcludes is reported and the rest of the change still applies; APPEND reports the unresolvable username rather than reporting success; a username differing only by case still resolves. - Passing locally: both of the above plus
ee/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb. Rubocop clean. GraphQL reference docs and both introspection schemas regenerated. - Verified end-to-end against a local GDK through the real GraphQL API: REPLACE, APPEND and REMOVE with an unresolvable username, case-insensitive resolution, duplicate usernames reported once including case variants of the same name, empty list still clears, and the blocked-reviewer sidebar case as a non-admin Developer.