Skip to content

Commit 04f2f88

Browse files
feat: bulk_publish reviewer_state/note/internal (GitLab 19.2+) (#606)
* feat: add bulk_publish reviewer_state params with call-time version gate Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten bulk_publish version gate and sync tools docs Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ac3574d commit 04f2f88

9 files changed

Lines changed: 311 additions & 7 deletions

File tree

docs/tools/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ MR lifecycle — create, update, merge, approve, plus diff/conflict inspection a
148148
| [`update_draft_note`](merge-requests.md#update_draft_note) | Update an existing draft note | ✏️ |
149149
| [`delete_draft_note`](merge-requests.md#delete_draft_note) | Delete a draft note | ✏️ |
150150
| [`publish_draft_note`](merge-requests.md#publish_draft_note) | Publish a single draft note | ✏️ |
151-
| [`bulk_publish_draft_notes`](merge-requests.md#bulk_publish_draft_notes) | Publish all draft notes for a merge request | ✏️ |
151+
| [`bulk_publish_draft_notes`](merge-requests.md#bulk_publish_draft_notes) | Publish all draft notes for a merge request. Optionally sets reviewer_state and posts a summary note (GitLab 19.2+). Can set reviewer_state even with no drafts. | ✏️ |
152152
| [`create_merge_request_thread`](merge-requests.md#create_merge_request_thread) | Create a new thread on a merge request | ✏️ |
153153
| [`resolve_merge_request_thread`](merge-requests.md#resolve_merge_request_thread) | Resolve a thread on a merge request | ✏️ |
154154
| [`list_merge_request_emoji_reactions`](merge-requests.md#list_merge_request_emoji_reactions) | List all emoji reactions on a merge request | 📖 |

docs/tools/merge-requests.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,14 +604,17 @@ Publish a single draft note
604604

605605
*✏️ Writes*
606606

607-
Publish all draft notes for a merge request
607+
Publish all draft notes for a merge request. Optionally sets reviewer_state and posts a summary note (GitLab 19.2+). Can set reviewer_state even with no drafts.
608608

609609
**Parameters**
610610

611611
| Parameter | Type | Required | Description |
612612
|---|---|:-:|---|
613613
| `project_id` | string || Project ID or complete URL-encoded path to project |
614614
| `merge_request_iid` | string || The IID of a merge request |
615+
| `reviewer_state` | enum (`requested_changes` \| `reviewed`) | | Set reviewer review state after publishing (GitLab 19.2+). Does not record a formal approval. Works even with no draft notes. |
616+
| `note` | string | | Summary note body to post on the merge request (GitLab 19.2+) |
617+
| `internal` | boolean | | If true, the summary note is internal (GitLab 19.2+, default false) |
615618

616619
### `create_merge_request_thread`
617620

index.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ import {
164164
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
165165
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
166166
import { checkForNewVersion } from "./utils/version-check.js";
167+
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
168+
import {
169+
buildBulkPublishDraftNotesBody,
170+
needsGitLab19_2BulkPublish,
171+
type BulkPublishDraftNotesBody,
172+
} from "./utils/bulk-publish-options.js";
167173
import {
168174
cleanMutuallyExclusiveIdUsernameOptions,
169175
LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS,
@@ -6289,15 +6295,38 @@ async function publishDraftNote(
62896295
}
62906296
}
62916297

6298+
async function fetchGitLabInstanceVersion(): Promise<string | null> {
6299+
try {
6300+
const response = await fetch(`${getEffectiveApiUrl()}/version`, {
6301+
...getFetchConfig(),
6302+
});
6303+
if (!response.ok) return null;
6304+
const data: unknown = await response.json();
6305+
if (
6306+
typeof data === "object" &&
6307+
data !== null &&
6308+
"version" in data &&
6309+
typeof data.version === "string"
6310+
) {
6311+
return data.version;
6312+
}
6313+
return null;
6314+
} catch {
6315+
return null;
6316+
}
6317+
}
6318+
62926319
/**
62936320
* Publish all draft notes for a merge request
62946321
* @param {string} projectId - The ID or URL-encoded path of the project
62956322
* @param {number|string} mergeRequestIid - The internal ID of the merge request
6323+
* @param {BulkPublishDraftNotesBody} options - Optional GitLab 19.2+ bulk_publish body fields
62966324
* @returns {Promise<GitLabDiscussionNote[]>} Array of published notes
62976325
*/
62986326
async function bulkPublishDraftNotes(
62996327
projectId: string,
6300-
mergeRequestIid: number | string
6328+
mergeRequestIid: number | string,
6329+
options: BulkPublishDraftNotesBody = {}
63016330
): Promise<GitLabDiscussionNote[]> {
63026331
projectId = decodeURIComponent(projectId);
63036332
const url = new URL(
@@ -6306,10 +6335,23 @@ async function bulkPublishDraftNotes(
63066335
)}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/draft_notes/bulk_publish`
63076336
);
63086337

6338+
const body = buildBulkPublishDraftNotesBody(options);
6339+
if (needsGitLab19_2BulkPublish(body)) {
6340+
await assertGitLabVersionAtLeast(
6341+
{
6342+
major: 19,
6343+
minor: 2,
6344+
feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
6345+
retryHint: "Omit reviewer_state, note, and internal, then retry.",
6346+
},
6347+
fetchGitLabInstanceVersion
6348+
);
6349+
}
6350+
63096351
const response = await fetch(url.toString(), {
63106352
...getFetchConfig(),
63116353
method: "POST", // Changed from PUT to POST
6312-
body: JSON.stringify({}), // Send empty body for POST request
6354+
body: JSON.stringify(body),
63136355
});
63146356

63156357
if (!response.ok) {
@@ -10687,9 +10729,9 @@ async function handleToolCall(params: any) {
1068710729

1068810730
case "bulk_publish_draft_notes": {
1068910731
const args = BulkPublishDraftNotesSchema.parse(params.arguments);
10690-
const { project_id, merge_request_iid } = args;
10732+
const { project_id, merge_request_iid, ...options } = args;
1069110733

10692-
const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid);
10734+
const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid, options);
1069310735
return {
1069410736
content: [{ type: "text", text: JSON.stringify(publishedNotes) }],
1069510737
};

schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3015,6 +3015,20 @@ export const PublishDraftNoteSchema = ProjectParamsSchema.extend({
30153015
// Bulk publish draft notes schema
30163016
export const BulkPublishDraftNotesSchema = ProjectParamsSchema.extend({
30173017
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
3018+
reviewer_state: z
3019+
.enum(["requested_changes", "reviewed"])
3020+
.optional()
3021+
.describe(
3022+
"Set reviewer review state after publishing (GitLab 19.2+). Does not record a formal approval. Works even with no draft notes."
3023+
),
3024+
note: z
3025+
.string()
3026+
.optional()
3027+
.describe("Summary note body to post on the merge request (GitLab 19.2+)"),
3028+
internal: z.coerce
3029+
.boolean()
3030+
.optional()
3031+
.describe("If true, the summary note is internal (GitLab 19.2+, default false)"),
30183032
});
30193033

30203034
// Schema for creating a new merge request thread
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import assert from "node:assert/strict";
2+
import { describe, test } from "node:test";
3+
import {
4+
buildBulkPublishDraftNotesBody,
5+
needsGitLab19_2BulkPublish,
6+
} from "../../utils/bulk-publish-options.js";
7+
8+
describe("When buildBulkPublishDraftNotesBody runs", () => {
9+
describe("with no-op defaults", () => {
10+
test("should omit internal false and empty note", () => {
11+
assert.deepEqual(buildBulkPublishDraftNotesBody({ internal: false, note: "" }), {});
12+
});
13+
});
14+
15+
describe("with meaningful 19.2 fields", () => {
16+
test("should keep reviewer_state, note, and internal true", () => {
17+
assert.deepEqual(
18+
buildBulkPublishDraftNotesBody({
19+
reviewer_state: "reviewed",
20+
note: "LGTM",
21+
internal: true,
22+
}),
23+
{ reviewer_state: "reviewed", note: "LGTM", internal: true }
24+
);
25+
});
26+
});
27+
});
28+
29+
describe("When needsGitLab19_2BulkPublish runs", () => {
30+
describe("with an empty body", () => {
31+
test("should return false", () => {
32+
assert.equal(needsGitLab19_2BulkPublish({}), false);
33+
assert.equal(needsGitLab19_2BulkPublish(buildBulkPublishDraftNotesBody({ internal: false })), false);
34+
});
35+
});
36+
37+
describe("with a non-empty body", () => {
38+
test("should return true", () => {
39+
assert.equal(needsGitLab19_2BulkPublish({ reviewer_state: "reviewed" }), true);
40+
});
41+
});
42+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from "node:assert/strict";
2+
import { describe, test } from "node:test";
3+
import {
4+
assertGitLabVersionAtLeast,
5+
isGitLabVersionAtLeast,
6+
parseGitLabVersion,
7+
type GitLabVersionFetcher,
8+
type GitLabVersionRequirement,
9+
} from "../../utils/gitlab-version-gate.js";
10+
11+
function versionFetcher(version: string | null): GitLabVersionFetcher {
12+
return async () => version;
13+
}
14+
15+
function bulkPublishRequirement(): GitLabVersionRequirement {
16+
return {
17+
major: 19,
18+
minor: 2,
19+
feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
20+
retryHint: "Omit reviewer_state, note, and internal, then retry.",
21+
};
22+
}
23+
24+
describe("When parseGitLabVersion runs", () => {
25+
describe("with a release suffix", () => {
26+
test("should parse major.minor.patch from ee builds", () => {
27+
assert.deepEqual(parseGitLabVersion("19.2.0-ee"), {
28+
major: 19,
29+
minor: 2,
30+
patch: 0,
31+
});
32+
});
33+
});
34+
35+
describe("with a malformed string", () => {
36+
test("should return null", () => {
37+
assert.equal(parseGitLabVersion("unknown"), null);
38+
assert.equal(parseGitLabVersion(""), null);
39+
});
40+
});
41+
});
42+
43+
describe("When isGitLabVersionAtLeast compares versions", () => {
44+
describe("with a version at or above the floor", () => {
45+
test("should return true", () => {
46+
assert.equal(isGitLabVersionAtLeast("19.2.0-ee", 19, 2), true);
47+
assert.equal(isGitLabVersionAtLeast("19.3.1", 19, 2), true);
48+
assert.equal(isGitLabVersionAtLeast("20.0.0", 19, 2), true);
49+
});
50+
});
51+
52+
describe("with a version below the floor", () => {
53+
test("should return false", () => {
54+
assert.equal(isGitLabVersionAtLeast("19.1.9-ee", 19, 2), false);
55+
assert.equal(isGitLabVersionAtLeast("17.5.0", 19, 2), false);
56+
});
57+
});
58+
59+
describe("with an unparseable version", () => {
60+
test("should return null", () => {
61+
assert.equal(isGitLabVersionAtLeast("not-a-version", 19, 2), null);
62+
});
63+
});
64+
});
65+
66+
describe("When assertGitLabVersionAtLeast runs", () => {
67+
describe("with an older instance version", () => {
68+
test("should throw a retryable error", async () => {
69+
await assert.rejects(
70+
() => assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("17.5.0-ee")),
71+
(error: unknown) => {
72+
assert.ok(error instanceof Error);
73+
assert.match(error.message, /GitLab 19\.2\+/);
74+
assert.match(error.message, /17\.5\.0-ee/);
75+
assert.match(error.message, /Omit reviewer_state/);
76+
return true;
77+
}
78+
);
79+
});
80+
});
81+
82+
describe("with a new enough instance version", () => {
83+
test("should resolve without throwing", async () => {
84+
await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher("19.2.0-ee"));
85+
});
86+
});
87+
88+
describe("with an unknown instance version", () => {
89+
test("should fail open and resolve", async () => {
90+
await assertGitLabVersionAtLeast(bulkPublishRequirement(), versionFetcher(null));
91+
});
92+
});
93+
});

tools/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,8 @@ export const allTools = [
492492
},
493493
{
494494
name: "bulk_publish_draft_notes",
495-
description: "Publish all draft notes for a merge request",
495+
description:
496+
"Publish all draft notes for a merge request. Optionally sets reviewer_state and posts a summary note (GitLab 19.2+). Can set reviewer_state even with no drafts.",
496497
inputSchema: toJSONSchema(BulkPublishDraftNotesSchema),
497498
},
498499
// --- Merge request emoji reaction tools ---

utils/bulk-publish-options.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** Optional GitLab 19.2+ body fields for draft_notes/bulk_publish. */
2+
export type BulkPublishDraftNotesBody = {
3+
reviewer_state?: "requested_changes" | "reviewed";
4+
note?: string;
5+
internal?: boolean;
6+
};
7+
8+
/**
9+
* Build the POST body. Omit no-op defaults (`internal: false`, empty `note`)
10+
* so older GitLab instances are not sent unknown fields.
11+
*/
12+
export function buildBulkPublishDraftNotesBody(
13+
options: BulkPublishDraftNotesBody
14+
): BulkPublishDraftNotesBody {
15+
const body: BulkPublishDraftNotesBody = {};
16+
if (options.reviewer_state !== undefined) {
17+
body.reviewer_state = options.reviewer_state;
18+
}
19+
if (options.note) {
20+
body.note = options.note;
21+
}
22+
if (options.internal === true) {
23+
body.internal = true;
24+
}
25+
return body;
26+
}
27+
28+
/** True when the body needs GitLab 19.2+ bulk_publish fields. */
29+
export function needsGitLab19_2BulkPublish(body: BulkPublishDraftNotesBody): boolean {
30+
return Object.keys(body).length > 0;
31+
}

utils/gitlab-version-gate.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Call-time GitLab instance version gate.
3+
*
4+
* Use when a tool exposes optional params that only exist on newer GitLab.
5+
* Schemas stay static; check before the API call and throw a retryable error.
6+
* Fail-open when the instance version cannot be fetched or parsed.
7+
*/
8+
9+
const VERSION_CORE = /^(\d+)\.(\d+)(?:\.(\d+))?/;
10+
11+
export type GitLabVersionParts = {
12+
major: number;
13+
minor: number;
14+
patch: number;
15+
};
16+
17+
/** Minimum GitLab version required for a feature. */
18+
export type GitLabVersionRequirement = {
19+
major: number;
20+
minor: number;
21+
/** What the caller is trying to use (shown in the error). */
22+
feature: string;
23+
/** How the agent should recover (shown in the error). */
24+
retryHint: string;
25+
};
26+
27+
/** Returns the instance version string (e.g. "19.2.0-ee"), or null if unknown. */
28+
export type GitLabVersionFetcher = () => Promise<string | null>;
29+
30+
/** Parse strings like "19.2.0-ee" / "17.5.3". Returns null if unparseable. */
31+
export function parseGitLabVersion(version: string): GitLabVersionParts | null {
32+
const match = VERSION_CORE.exec(version.trim());
33+
if (!match) return null;
34+
35+
const major = Number.parseInt(match[1], 10);
36+
const minor = Number.parseInt(match[2], 10);
37+
const patch = match[3] ? Number.parseInt(match[3], 10) : 0;
38+
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
39+
return null;
40+
}
41+
42+
return { major, minor, patch };
43+
}
44+
45+
/**
46+
* Compare a GitLab version string against a major.minor floor.
47+
* Returns null when the version string cannot be parsed.
48+
*/
49+
export function isGitLabVersionAtLeast(
50+
version: string,
51+
major: number,
52+
minor: number
53+
): boolean | null {
54+
const parsed = parseGitLabVersion(version);
55+
if (!parsed) return null;
56+
if (parsed.major !== major) return parsed.major > major;
57+
return parsed.minor >= minor;
58+
}
59+
60+
/**
61+
* Throws when the instance is known to be older than `requirement`.
62+
* Does nothing when the version is unknown (fail-open).
63+
*/
64+
export async function assertGitLabVersionAtLeast(
65+
requirement: GitLabVersionRequirement,
66+
fetchVersion: GitLabVersionFetcher
67+
): Promise<void> {
68+
const current = await fetchVersion();
69+
if (current === null) return;
70+
71+
const ok = isGitLabVersionAtLeast(current, requirement.major, requirement.minor);
72+
if (ok === null || ok) return;
73+
74+
throw new Error(
75+
`GitLab ${requirement.major}.${requirement.minor}+ required for ${requirement.feature} ` +
76+
`(instance reports ${current}). ${requirement.retryHint}`
77+
);
78+
}

0 commit comments

Comments
 (0)