Skip to content

Commit 1cef037

Browse files
authored
Merge pull request #862 from ALERTua/feat/rebase-head-changed
feat: report whether rebase_merge_request moved the head
2 parents 157644e + 9a9e116 commit 1cef037

9 files changed

Lines changed: 37 additions & 11 deletions

File tree

‎.well-known/mcp/server-card.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10829,7 +10829,7 @@
1082910829
},
1083010830
{
1083110831
"name": "rebase_merge_request",
10832-
"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.",
10832+
"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.",
1083310833
"inputSchema": {
1083410834
"type": "object",
1083510835
"properties": {

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
88

99
##### Changed
1010

11+
- `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)).
1112
- 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.
1213
- Tool argument validation failures are now returned as tool results with `isError: true` instead of JSON-RPC errors, so the model sees which arguments to fix and can retry. The message names the tool and each invalid field, for example `Invalid arguments for verify_namespace: path: Invalid input: expected string, received undefined`. Clients that caught these as protocol errors (`MCP error -32603`) must now check `isError` on the result. GitLab responses that fail schema parsing are still JSON-RPC errors, now reported as `Unexpected GitLab response for <tool>: ...` instead of `Invalid arguments: ...`.
1314

‎docs/tools/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ MR lifecycle — create, update, merge, approve, plus diff/conflict inspection a
180180
| Tool | What it does | R/W |
181181
|---|---|:-:|
182182
| [`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. | ✏️ |
183-
| [`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. | ✏️ |
183+
| [`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. | ✏️ |
184184
| [`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. | ✏️ |
185185
| [`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. | ✏️ |
186186
| [`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. | 📖 |

‎docs/tools/merge-requests.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Merge a merge request. Use this only after checking the merge request approval,
8080

8181
*✏️ Writes*
8282

83-
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.
83+
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.
8484

8585
**Parameters**
8686

‎index.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12521,17 +12521,27 @@ async function handleToolCall(params: any) {
1252112521
timeout_seconds,
1252212522
poll_interval_seconds,
1252312523
} = RebaseMergeRequestSchema.parse(params.arguments);
12524-
const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
1252512524
if (!wait) {
12525+
const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
1252612526
return { content: [{ type: "text", text: JSON.stringify(enqueued) }] };
1252712527
}
12528+
const { mergeRequest: previous } = await getMergeRequestRebaseStatus(
12529+
project_id,
12530+
merge_request_iid
12531+
);
12532+
await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
1252812533
const { mergeRequest } = await waitForStatus(
1252912534
signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal),
1253012535
TERMINAL_REBASE_STATUSES,
1253112536
timeout_seconds,
1253212537
poll_interval_seconds
1253312538
);
12534-
return { content: [{ type: "text", text: JSON.stringify(mergeRequest) }] };
12539+
const rebaseResult = {
12540+
...mergeRequest,
12541+
previous_sha: previous.sha,
12542+
head_changed: mergeRequest.sha !== previous.sha,
12543+
};
12544+
return { content: [{ type: "text", text: JSON.stringify(rebaseResult) }] };
1253512545
}
1253612546

1253712547
case "get_merge_request_approval_state": {

‎skills/gitlab-mcp/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ Enable with `GITLAB_TOOLSETS=vulnerabilities` (requires GitLab Ultimate).
125125
- **MR lookup**: provide `merge_request_iid` or `source_branch`
126126
- **list_issues**: default scope = created by current user. Use `scope: "all"` for all issues
127127
- **list_merge_requests**: without project_id returns user's MRs across all projects
128+
- **rebase_merge_request**: with `wait` true (default) returns the merge request plus `previous_sha` and `head_changed`
128129
- **CI catalog resource lookup**: provide exactly one of `id` or `full_path`
129130
- **emoji reactions**: merge request, issue, and work item reaction tools use GitLab emoji names like `thumbsup`, `rocket`, or `eyes`
130131
- **work items**: status and custom fields require GitLab Premium/Ultimate features

‎skills/gitlab-mcp/reference/merge-requests.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ rebase_merge_request
6969
project_id: "my-group/my-project"
7070
merge_request_iid: 42
7171
skip_ci: true # optional, no pipeline for the rebased branch
72-
wait: true # default; polls until the rebase ends
72+
wait: true # default; polls until the rebase ends and returns previous_sha and head_changed
7373
```
7474

7575
**Warning**: `rebase_merge_request` is destructive - it force-pushes the rebased source branch.

‎test/test-rebase-merge-request.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,15 @@ describe("rebase_merge_request tool", () => {
175175
let mockGitLabUrl: string;
176176
const rebaseBodies = new Map<string, Record<string, unknown>>();
177177
const statusQueries = new Map<string, Array<Record<string, unknown>>>();
178+
const statusQueriesBeforeRebase = new Map<string, number>();
178179

179180
function addRebaseHandler(iid: string) {
180181
mockGitLab.addMockHandler(
181182
"put",
182183
`/projects/${TEST_PROJECT_ID}/merge_requests/${iid}/rebase`,
183184
(req, res) => {
184185
rebaseBodies.set(iid, req.body);
186+
statusQueriesBeforeRebase.set(iid, statusQueries.get(iid)?.length ?? 0);
185187
res.status(202).json({ rebase_in_progress: true });
186188
}
187189
);
@@ -212,12 +214,18 @@ describe("rebase_merge_request tool", () => {
212214
addRebaseHandler(iid);
213215
}
214216
addStatusHandler(MR_ENQUEUE_ONLY, [{ rebase_in_progress: true }]);
217+
// The first GET of a waiting call reads the head before the rebase.
215218
addStatusHandler(MR_SUCCESS, [
219+
{ rebase_in_progress: false },
216220
{ rebase_in_progress: true },
217221
{ rebase_in_progress: false, sha: NEW_SHA },
218222
]);
219-
addStatusHandler(MR_FAILED, [{ rebase_in_progress: false, merge_error: REBASE_FAILED }]);
223+
addStatusHandler(MR_FAILED, [
224+
{ rebase_in_progress: false },
225+
{ rebase_in_progress: false, merge_error: REBASE_FAILED },
226+
]);
220227
addStatusHandler(MR_TIMEOUT, [{ rebase_in_progress: true }]);
228+
addStatusHandler(MR_FORBIDDEN, [{ rebase_in_progress: false }]);
221229

222230
mockGitLab.addMockHandler(
223231
"put",
@@ -263,7 +271,7 @@ describe("rebase_merge_request tool", () => {
263271
assert.deepStrictEqual(rebaseBodies.get(MR_SKIP_CI), { skip_ci: true });
264272
});
265273

266-
test("rebase_merge_request polls until the rebase ends and returns the merge request", async () => {
274+
test("rebase_merge_request polls until the rebase ends and reports that the head moved", async () => {
267275
const result = await callTool(
268276
"rebase_merge_request",
269277
{ project_id: TEST_PROJECT_ID, merge_request_iid: MR_SUCCESS, poll_interval_seconds: 1 },
@@ -273,12 +281,15 @@ describe("rebase_merge_request tool", () => {
273281
assert.strictEqual(result.rebase_in_progress, false);
274282
assert.strictEqual(result.merge_error, null);
275283
assert.strictEqual(result.sha, NEW_SHA);
284+
assert.strictEqual(result.previous_sha, OLD_SHA);
285+
assert.strictEqual(result.head_changed, true);
276286
const queries = statusQueries.get(MR_SUCCESS) ?? [];
277-
assert.strictEqual(queries.length, 2);
287+
assert.strictEqual(queries.length, 3);
288+
assert.strictEqual(statusQueriesBeforeRebase.get(MR_SUCCESS), 1);
278289
assert.ok(queries.every(query => query.include_rebase_in_progress === "true"));
279290
});
280291

281-
test("rebase_merge_request returns merge_error when the rebase fails", async () => {
292+
test("rebase_merge_request returns merge_error and an unmoved head when the rebase fails", async () => {
282293
const result = await callTool(
283294
"rebase_merge_request",
284295
{ project_id: TEST_PROJECT_ID, merge_request_iid: MR_FAILED },
@@ -287,6 +298,9 @@ describe("rebase_merge_request tool", () => {
287298

288299
assert.strictEqual(result.rebase_in_progress, false);
289300
assert.strictEqual(result.merge_error, REBASE_FAILED);
301+
assert.strictEqual(result.previous_sha, OLD_SHA);
302+
assert.strictEqual(result.head_changed, false);
303+
assert.strictEqual(statusQueries.get(MR_FAILED)?.length, 2);
290304
});
291305

292306
test("rebase_merge_request reports a timeout while the rebase still runs", async () => {

‎tools/tool-descriptions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const TOOL_GUIDANCE: Readonly<Record<string, string>> = {
66
unapprove_merge_request:
77
"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.",
88
rebase_merge_request:
9-
"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.",
9+
"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.",
1010
get_merge_request_approval_state:
1111
"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.",
1212
get_merge_request_conflicts:

0 commit comments

Comments
 (0)