Validate conflict resolution input before calling Gitaly

The web UI only ever sends paths and sections it built itself from the server's own conflict list, so master never validates this input before handing it to Gitaly. An API client can send a path that is not actually in conflict, or a malformed sections value, and today that reaches Gitaly and comes back as an internal error that looks like a Gitaly outage (503, plus a Sentry event per attempt) instead of a client mistake. This issue adds validation in the service before the Gitaly call.

Scope

  • Refuse a file whose path is not one of the conflicting files (checked via FileCollection#file_for_path): message "File %{path} is not in conflict.", reason file_not_in_conflict, 400, no Sentry event.
  • Refuse a file with neither content nor a non-empty sections hash: message "File %{path} needs either sections or content.", reason incomplete_file, 400.
  • Pass commit_message.presence instead of commit_message to the resolve call, so a blank string falls back to FileCollection#default_commit_message the same way nil already does.
  • Rescue ConflictSideMissing raised by the file_for_path check and return the not_resolvable_in_ui reason.

Done when

  • A request for a path that is not in conflict gets a 400 with the file_not_in_conflict message, not a 503.
  • A request with an empty or non-hash sections value and no content gets a 400 with the incomplete_file message, not an emptied file or a 503.
  • A blank commit_message behaves like nil (falls back to the default message) instead of raising.
  • Specs cover each case plus the existing valid-input path.
Detailed context for AI agents

Depends on the refactor issue (return a ServiceResponse), which this builds validation on top of.

Current behaviour on master for each case:

  • Path not in conflict: reaches Gitaly, which answers with an internal error (13:NoMethodError: undefined method 'resolve_lines' for nil). That is mapped to Gitlab::Git::CommandError, so the controller answers 503 "Gitaly is not available" via HandlesGitalyErrors, and a Sentry event is created per attempt. The web UI never triggers this because it only ever sends paths it read from the server's own conflict list, but an API or GraphQL client is free to send anything. Gitlab::Conflict::FileCollection#file_for_path(old_path, new_path) can check this before the Gitaly call.
  • Empty sections with no content: Gitaly treats an empty sections hash as a content resolution and would commit the file as empty. A sections value that is not a hash (for example a string) makes Gitaly answer 13:json: cannot unmarshal string ..., which again reads as a Gitaly outage rather than bad input. Note the controller's permit already strips non-hash sections for web requests, so a non-hash value can currently only reach this code path through GraphQL or a direct API call, not through the controller.
  • Blank commit_message: an empty string (as opposed to nil) reaches Gitaly and raises ArgumentError ("3:empty CommitMessage", INVALID_ARGUMENT), which surfaces as a 500. nil already falls back correctly, because Gitlab::Conflict::FileCollection#resolve does commit_message || default_commit_message. Passing params[:commit_message].presence from the service makes blank behave the same as nil without changing the nil case.
  • ConflictSideMissing: calling file_for_path lists the conflicts again. If can_be_resolved_in_ui? was cached as true but a side has since gone missing, that listing raises Gitlab::Git::Conflict::Resolver::ConflictSideMissing. Rescue it and return the existing not_resolvable_in_ui reason (404 on the web endpoint, same as master's answer for conflicts that cannot be resolved in the UI). UnsupportedEncoding cannot reach this path, so it needs no rescue.

None of this is reachable from the web conflicts page today since it only sends well-formed input built server-side; the value here is entirely for API/GraphQL clients feeding the same service, which is why it belongs in the service rather than the controller.