From 9a9e116a2f7add847f4b631a709bc06f90e60ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Fri, 16 Oct 2026 12:21:36 +0300 Subject: [PATCH] feat: report whether rebase_merge_request moved the head Co-Authored-By: Claude Opus 5.5 --- .well-known/mcp/server-card.json | 2 +- CHANGELOG.md | 1 + docs/tools/index.md | 2 +- docs/tools/merge-requests.md | 2 +- index.ts | 14 ++++++++++-- skills/gitlab-mcp/SKILL.md | 1 + skills/gitlab-mcp/reference/merge-requests.md | 2 +- test/test-rebase-merge-request.ts | 22 +++++++++++++++---- tools/tool-descriptions.ts | 2 +- 9 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.well-known/mcp/server-card.json b/.well-known/mcp/server-card.json index 6b83efa3c..1436bac27 100644 --- a/.well-known/mcp/server-card.json +++ b/.well-known/mcp/server-card.json @@ -10829,7 +10829,7 @@ }, { "name": "rebase_merge_request", - "description": "Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.", + "description": "Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.", "inputSchema": { "type": "object", "properties": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc794df5..5181445a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). ##### Changed +- `rebase_merge_request` with `wait` true (default) now also returns `previous_sha` (the head `sha` before the rebase) and `head_changed`, so a caller sees at once whether the rebase moved the head ([#862](https://github.com/zereight/gitlab-mcp/pull/862)). - Boolean tool arguments now parse the string `"false"` as `false` ([#848](https://github.com/zereight/gitlab-mcp/pull/848)). Other non-boolean strings such as `""` or `"yes"`, previously coerced to `true`, are now rejected with a validation error. ##### Added diff --git a/docs/tools/index.md b/docs/tools/index.md index 90f9efe93..54940d35e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -180,7 +180,7 @@ MR lifecycle — create, update, merge, approve, plus diff/conflict inspection a | Tool | What it does | R/W | |---|---|:-:| | [`merge_merge_request`](merge-requests.md#merge_merge_request) | Merge a merge request. Use this only after checking the merge request approval, conflict, and pipeline state; use `approve_merge_request` to approve rather than merge. The operation changes repository state and may squash commits, schedule auto-merge, or delete the source branch, so it requires merge permission and returns GitLab's merge result or a mergeability error. Pass `sha` from `get_merge_request` (`sha` or `diff_refs.head_sha`); GitLab 19.2+ groups may reject merges without it. | ✏️ | -| [`rebase_merge_request`](merge-requests.md#rebase_merge_request) | Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. | ✏️ | +| [`rebase_merge_request`](merge-requests.md#rebase_merge_request) | Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. | ✏️ | | [`approve_merge_request`](merge-requests.md#approve_merge_request) | Approve a merge request. Use this to record an approval on an existing merge request; it does not merge the request or change its source branch. The operation changes review state, may require re-authentication or approval permission, and returns the updated approval result or a permission/state error. | ✏️ | | [`unapprove_merge_request`](merge-requests.md#unapprove_merge_request) | Unapprove a merge request. Use this to remove the current user's approval from an existing merge request; use `merge_merge_request` only when you intend to merge. The operation changes review state and requires approval permission, and GitLab returns the updated result or an error when the request or approval is unavailable. | ✏️ | | [`get_merge_request_approval_state`](merge-requests.md#get_merge_request_approval_state) | Get merge request approval details including approvers. Use this to inspect approval rules and approvers before deciding whether a merge request can be merged; use `approve_merge_request` to change approval state. It is read-only and returns the approval-state response, while missing requests, unsupported GitLab versions, and permission failures are reported as errors. | 📖 | diff --git a/docs/tools/merge-requests.md b/docs/tools/merge-requests.md index 066a12795..068974f9e 100644 --- a/docs/tools/merge-requests.md +++ b/docs/tools/merge-requests.md @@ -80,7 +80,7 @@ Merge a merge request. Use this only after checking the merge request approval, *✏️ Writes* -Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. +Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. **Parameters** diff --git a/index.ts b/index.ts index 07588c3e0..f53d23b81 100644 --- a/index.ts +++ b/index.ts @@ -12520,17 +12520,27 @@ async function handleToolCall(params: any) { timeout_seconds, poll_interval_seconds, } = RebaseMergeRequestSchema.parse(params.arguments); - const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci); if (!wait) { + const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci); return { content: [{ type: "text", text: JSON.stringify(enqueued) }] }; } + const { mergeRequest: previous } = await getMergeRequestRebaseStatus( + project_id, + merge_request_iid + ); + await rebaseMergeRequest(project_id, merge_request_iid, skip_ci); const { mergeRequest } = await waitForStatus( signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal), TERMINAL_REBASE_STATUSES, timeout_seconds, poll_interval_seconds ); - return { content: [{ type: "text", text: JSON.stringify(mergeRequest) }] }; + const rebaseResult = { + ...mergeRequest, + previous_sha: previous.sha, + head_changed: mergeRequest.sha !== previous.sha, + }; + return { content: [{ type: "text", text: JSON.stringify(rebaseResult) }] }; } case "get_merge_request_approval_state": { diff --git a/skills/gitlab-mcp/SKILL.md b/skills/gitlab-mcp/SKILL.md index 999daaac3..40a8c4343 100644 --- a/skills/gitlab-mcp/SKILL.md +++ b/skills/gitlab-mcp/SKILL.md @@ -125,6 +125,7 @@ Enable with `GITLAB_TOOLSETS=vulnerabilities` (requires GitLab Ultimate). - **MR lookup**: provide `merge_request_iid` or `source_branch` - **list_issues**: default scope = created by current user. Use `scope: "all"` for all issues - **list_merge_requests**: without project_id returns user's MRs across all projects +- **rebase_merge_request**: with `wait` true (default) returns the merge request plus `previous_sha` and `head_changed` - **CI catalog resource lookup**: provide exactly one of `id` or `full_path` - **emoji reactions**: merge request, issue, and work item reaction tools use GitLab emoji names like `thumbsup`, `rocket`, or `eyes` - **work items**: status and custom fields require GitLab Premium/Ultimate features diff --git a/skills/gitlab-mcp/reference/merge-requests.md b/skills/gitlab-mcp/reference/merge-requests.md index 798f72894..9b22a83db 100644 --- a/skills/gitlab-mcp/reference/merge-requests.md +++ b/skills/gitlab-mcp/reference/merge-requests.md @@ -69,7 +69,7 @@ rebase_merge_request project_id: "my-group/my-project" merge_request_iid: 42 skip_ci: true # optional, no pipeline for the rebased branch - wait: true # default; polls until the rebase ends + wait: true # default; polls until the rebase ends and returns previous_sha and head_changed ``` **Warning**: `rebase_merge_request` is destructive - it force-pushes the rebased source branch. diff --git a/test/test-rebase-merge-request.ts b/test/test-rebase-merge-request.ts index 36ddb76de..14e04f696 100644 --- a/test/test-rebase-merge-request.ts +++ b/test/test-rebase-merge-request.ts @@ -175,6 +175,7 @@ describe("rebase_merge_request tool", () => { let mockGitLabUrl: string; const rebaseBodies = new Map>(); const statusQueries = new Map>>(); + const statusQueriesBeforeRebase = new Map(); function addRebaseHandler(iid: string) { mockGitLab.addMockHandler( @@ -182,6 +183,7 @@ describe("rebase_merge_request tool", () => { `/projects/${TEST_PROJECT_ID}/merge_requests/${iid}/rebase`, (req, res) => { rebaseBodies.set(iid, req.body); + statusQueriesBeforeRebase.set(iid, statusQueries.get(iid)?.length ?? 0); res.status(202).json({ rebase_in_progress: true }); } ); @@ -212,12 +214,18 @@ describe("rebase_merge_request tool", () => { addRebaseHandler(iid); } addStatusHandler(MR_ENQUEUE_ONLY, [{ rebase_in_progress: true }]); + // The first GET of a waiting call reads the head before the rebase. addStatusHandler(MR_SUCCESS, [ + { rebase_in_progress: false }, { rebase_in_progress: true }, { rebase_in_progress: false, sha: NEW_SHA }, ]); - addStatusHandler(MR_FAILED, [{ rebase_in_progress: false, merge_error: REBASE_FAILED }]); + addStatusHandler(MR_FAILED, [ + { rebase_in_progress: false }, + { rebase_in_progress: false, merge_error: REBASE_FAILED }, + ]); addStatusHandler(MR_TIMEOUT, [{ rebase_in_progress: true }]); + addStatusHandler(MR_FORBIDDEN, [{ rebase_in_progress: false }]); mockGitLab.addMockHandler( "put", @@ -263,7 +271,7 @@ describe("rebase_merge_request tool", () => { assert.deepStrictEqual(rebaseBodies.get(MR_SKIP_CI), { skip_ci: true }); }); - test("rebase_merge_request polls until the rebase ends and returns the merge request", async () => { + test("rebase_merge_request polls until the rebase ends and reports that the head moved", async () => { const result = await callTool( "rebase_merge_request", { project_id: TEST_PROJECT_ID, merge_request_iid: MR_SUCCESS, poll_interval_seconds: 1 }, @@ -273,12 +281,15 @@ describe("rebase_merge_request tool", () => { assert.strictEqual(result.rebase_in_progress, false); assert.strictEqual(result.merge_error, null); assert.strictEqual(result.sha, NEW_SHA); + assert.strictEqual(result.previous_sha, OLD_SHA); + assert.strictEqual(result.head_changed, true); const queries = statusQueries.get(MR_SUCCESS) ?? []; - assert.strictEqual(queries.length, 2); + assert.strictEqual(queries.length, 3); + assert.strictEqual(statusQueriesBeforeRebase.get(MR_SUCCESS), 1); assert.ok(queries.every(query => query.include_rebase_in_progress === "true")); }); - test("rebase_merge_request returns merge_error when the rebase fails", async () => { + test("rebase_merge_request returns merge_error and an unmoved head when the rebase fails", async () => { const result = await callTool( "rebase_merge_request", { project_id: TEST_PROJECT_ID, merge_request_iid: MR_FAILED }, @@ -287,6 +298,9 @@ describe("rebase_merge_request tool", () => { assert.strictEqual(result.rebase_in_progress, false); assert.strictEqual(result.merge_error, REBASE_FAILED); + assert.strictEqual(result.previous_sha, OLD_SHA); + assert.strictEqual(result.head_changed, false); + assert.strictEqual(statusQueries.get(MR_FAILED)?.length, 2); }); test("rebase_merge_request reports a timeout while the rebase still runs", async () => { diff --git a/tools/tool-descriptions.ts b/tools/tool-descriptions.ts index 8ffed2b2a..d4af26917 100644 --- a/tools/tool-descriptions.ts +++ b/tools/tool-descriptions.ts @@ -6,7 +6,7 @@ const TOOL_GUIDANCE: Readonly> = { unapprove_merge_request: "Use this to remove the current user's approval from an existing merge request; use `merge_merge_request` only when you intend to merge. The operation changes review state and requires approval permission, and GitLab returns the updated result or an error when the request or approval is unavailable.", rebase_merge_request: - "Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.", + "Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.", get_merge_request_approval_state: "Use this to inspect approval rules and approvers before deciding whether a merge request can be merged; use `approve_merge_request` to change approval state. It is read-only and returns the approval-state response, while missing requests, unsupported GitLab versions, and permission failures are reported as errors.", get_merge_request_conflicts: