Return a ServiceResponse from MergeRequests::Conflicts::ResolveService

MergeRequests::Conflicts::ResolveService#execute is currently three lines: it builds a Gitlab::Conflict::FileCollection and calls resolve, raising on failure and returning nothing useful. All the checks that make resolving conflicts safe (push access, resolvable-in-UI, already-resolved, error handling) live in Projects::MergeRequests::ConflictsController#resolve_conflicts instead, which only works from a browser. This issue moves those checks into the service with no behaviour change, so later work (API access) can build on a clean, testable interface.

Scope

  • Move into the service, in master's exact order: the push-access check (authorize_can_resolve_conflicts!), the can_be_resolved_in_ui? check, the MergeRequestActivityUniqueCounter.track_resolve_conflict_action call, the can_be_merged? ("already resolved") check, and the rescue of Gitlab::Git::Conflict::Resolver::ResolutionError and Gitlab::Git::PreReceiveError.
  • Service returns ServiceResponse.success(payload: { merge_request: }) on success, ServiceResponse.error(message:, reason:) on failure.
  • Reason constants are owned and defined by the service.
  • Controller maps each reason back to the exact status code and body master returns today (404 / 400 / 422).
  • Controller keeps its before_action :authorize_can_resolve_conflicts! (defense in depth, and it also covers the show and conflict_for_path actions, not only resolve_conflicts).
  • Gitaly errors keep propagating to HandlesGitalyErrors unchanged for now (a later issue moves that into the service).

Done when

  • The existing controller spec for resolve_conflicts passes unchanged, proving nothing observable changed for the web endpoint.
  • New service specs cover each reason and each response directly.
  • No status code, message, or response body changes.
Detailed context for AI agents

Current controller flow in Projects::MergeRequests::ConflictsController#resolve_conflicts on master, in order:

  1. before_action :authorize_can_resolve_conflicts! builds @conflicts_list = ListService.new(@merge_request) and renders 404 unless @conflicts_list.can_be_resolved_by?(current_user) (push access to the source branch).
  2. can_be_resolved_in_ui? is checked early (before the main action body) so HandlesGitalyErrors can catch any Gitaly outage during that check.
  3. In the action itself: 404 unless can_be_resolved_in_ui?.
  4. MergeRequestActivityUniqueCounter.track_resolve_conflict_action(user: current_user).
  5. 400 with "The merge conflicts for this merge request have already been resolved." if @merge_request.can_be_merged?.
  6. Calls MergeRequests::Conflicts::ResolveService.
  7. Rescues Gitlab::Git::Conflict::Resolver::ResolutionError -> 400 with the exception's message.
  8. Rescues Gitlab::Git::PreReceiveError -> 422 with the exception's message.

Gitaly errors (HandlesGitalyErrors::GITALY_ERRORS) are handled separately by the HandlesGitalyErrors concern's rescue_from, which tracks the exception and renders 503 { error: gitaly_unavailable_message } for JSON requests. That is out of scope for this issue (see the Gitaly-unavailability issue).

Permitted params: commit_message, files: [:old_path, :new_path, :content, { sections: {} }].

ListService#can_be_resolved_in_ui? returns false unless the merge request cannot_be_merged?, has complete diff refs, and has no missing branch; otherwise it delegates to Gitlab::Conflict::FileCollection#can_be_resolved_in_ui?, which is cached via Redis cache_method and swallows parser, encoding, and side-missing errors (and non-Gitaly CommandError), re-raising only when Gitaly is genuinely unavailable.

Note the ordering bug this creates on master: because can_be_resolved_in_ui? is false for a mergeable (already resolved) merge request, the "already been resolved" 400 is currently unreachable - an already-resolved MR gets a 404 instead. Do not fix that here; it is intentionally deferred to a follow-up issue so this move stays behaviour-neutral and provable via the unchanged spec. Preserve the exact current (buggy) order when moving the code.

This is the foundation for the rest of the epic: input validation, the already-resolved/still-checking fix, Gitaly-unavailability reporting, and the eventual GraphQL mutation all build on this service's ServiceResponse shape and reason constants.

Prior art: this move was previously attempted as !256973 (closed), closed unmerged on 2026-09-25 because it mixed this pure move with behaviour changes in the same MR, which made review difficult. Re-check its review thread for edge cases before implementing.