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.", reasonfile_not_in_conflict, 400, no Sentry event. - Refuse a file with neither
contentnor a non-emptysectionshash: message "File %{path} needs either sections or content.", reasonincomplete_file, 400. - Pass
commit_message.presenceinstead ofcommit_messageto the resolve call, so a blank string falls back toFileCollection#default_commit_messagethe same waynilalready does. - Rescue
ConflictSideMissingraised by thefile_for_pathcheck and return thenot_resolvable_in_uireason.
Done when
- A request for a path that is not in conflict gets a 400 with the
file_not_in_conflictmessage, not a 503. - A request with an empty or non-hash
sectionsvalue and nocontentgets a 400 with theincomplete_filemessage, not an emptied file or a 503. - A blank
commit_messagebehaves likenil(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 toGitlab::Git::CommandError, so the controller answers 503 "Gitaly is not available" viaHandlesGitalyErrors, 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
sectionswith nocontent: Gitaly treats an emptysectionshash as a content resolution and would commit the file as empty. Asectionsvalue that is not a hash (for example a string) makes Gitaly answer13:json: cannot unmarshal string ..., which again reads as a Gitaly outage rather than bad input. Note the controller'spermitalready strips non-hashsectionsfor 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 tonil) reaches Gitaly and raisesArgumentError("3:empty CommitMessage",INVALID_ARGUMENT), which surfaces as a 500.nilalready falls back correctly, becauseGitlab::Conflict::FileCollection#resolvedoescommit_message || default_commit_message. Passingparams[:commit_message].presencefrom the service makes blank behave the same asnilwithout changing thenilcase. ConflictSideMissing: callingfile_for_pathlists the conflicts again. Ifcan_be_resolved_in_ui?was cached as true but a side has since gone missing, that listing raisesGitlab::Git::Conflict::Resolver::ConflictSideMissing. Rescue it and return the existingnot_resolvable_in_uireason (404 on the web endpoint, same as master's answer for conflicts that cannot be resolved in the UI).UnsupportedEncodingcannot 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.