gitbutlerapp/gitbutler · error

Failed to merge merge request

Error message

Failed to merge merge request: {}

What it means

GitLabClient::merge_merge_request sends a PUT to the GitLab merge-request merge endpoint and treats any non-2xx HTTP status from GitLab as a failure. The GitLab API rejected the merge request; the response status (e.g. 405, 406, 409, 422) is included in the message. The error body text is NOT captured here, so the message only reveals the status code.

Solutions

  1. Check the status code in the message: 405 usually means the MR is not mergeable (check `merge_status` / `detailed_merge_status` first), 409 means sha mismatch or already merged.
  2. Re-fetch the MR and verify it is mergeable (no unresolved threads, pipeline green, not draft) before calling merge_merge_request.
  3. Pass the correct `sha` parameter (the head commit at fetch time) so GitLab can reject stale merges explicitly instead of silently merging; if 409, re-fetch and retry.
  4. Verify the authenticated token has at least Developer role and merge rights on the target branch (protected branch rules).
  5. Improve diagnosability by reading the response body like the sibling methods do (response.text()) and including it in the error.

Example fix

// before
let response = self.client.put(&url).json(&body).send().await?;
if !response.status().is_success() {
    bail!("Failed to merge merge request: {}", response.status());
}
// after
let response = self.client.put(&url).json(&body).send().await?;
if !response.status().is_success() {
    let status = response.status();
    let error_text = response.text().await.unwrap_or_default();
    bail!("Failed to merge merge request: {status} - {error_text}");
}
Defensive patterns

Strategy: try-catch

Validate before calling

let mr = client.get_merge_request(project_id, mr_iid).await?;
assert_eq!(mr.draft, false);
assert!(mr.merge_status == "can_be_merged");
assert!(mr.blocking_discussions_resolved == Some(true));

Try / catch

match client.merge_merge_request(project_id, mr_iid, params).await {
    Ok(()) => println!("merged"),
    Err(e) => {
        let msg = e.to_string();
        if msg.contains("409") {
            // stale sha or already merged: re-fetch MR and retry once
        } else if msg.contains("405") {
            // not mergeable: check pipeline/discussions
        } else {
            return Err(e);
        }
    }
}

Prevention

When it happens

Trigger: Calling merge_merge_request when GitLab returns a non-success status: MR is not mergeable (unresolved discussions, failing pipelines/merge checks), MR already merged or closed, insufficient permissions to merge, sha mismatch (MR was updated since fetched), or squash/merge params invalid for the project settings.

Common situations: Merging an MR whose branch was just updated by another commit (stale sha); CI pipeline still running with 'Pipelines must succeed' enabled; user lacks Developer+ role; MR in draft state when project blocks merging drafts; MR already merged by someone else.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18). Data as JSON: /api/errors/2df720318c1648b0. Report an issue: GitHub.

Appendix: source

Thrown at crates/but-gitlab/src/client.rs:494

        #[derive(Serialize)]
        struct MergeMergeRequestBody {
            #[serde(skip_serializing_if = "Option::is_none")]
            squash: Option<bool>,
        }

        let url = format!(
            "{}/projects/{}/merge_requests/{}/merge",
            self.base_url, params.project_id, params.mr_iid
        );

        let body = MergeMergeRequestBody {
            squash: params.squash,
        };

        let response = self.client.put(&url).json(&body).send().await?;

        if !response.status().is_success() {
            bail!("Failed to merge merge request: {}", response.status());
        }

        Ok(())
    }

    pub async fn set_merge_request_draft_state(
        &self,
        params: &SetMergeRequestDraftStateParams,
    ) -> Result<()> {
        let project_id = params.project_id.clone();
        let mr = self
            .get_merge_request(project_id.clone(), params.mr_iid)
            .await?;
        let next_title = update_draft_state_in_title(&mr.title, params.is_draft);

        if next_title == mr.title {
            return Ok(());
        }

View on GitHub (pinned to 58e5313667)