Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

_Nothing yet. New entries land here between releases._
### Added

- **`unresolved_only` filter on `list_merge_request_discussions`** — pass `unresolved_only: true` to fetch only threads that still have at least one unresolved resolvable note. Filtering is performed client-side because the GitLab Discussions API has no server-side equivalent.
- **Resolution metadata on MR discussion notes** — `GitLabNoteSchema` now models `resolved`, `resolved_by`, and `resolved_at`, so the resolution data GitLab returns for resolvable notes is no longer dropped at the Zod boundary.
- **Per-thread `resolvable` / `resolved` aggregate** in `formatDiscussionsResponse` output, plus an `(N unresolved, M resolved threads)` count appended to the summary line whenever resolvable threads are present. Issue discussions (no resolvable notes) keep the legacy short summary.
- **Tests** — 10 new vitest cases (96 total, up from 86) covering the new schema fields, formatter aggregation, and summary output.

## [0.8.1] - 2026-05-20

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yoda.digital/gitlab-mcp-server",
"version": "0.8.1",
"version": "0.9.0",
"description": "GitLab MCP Server - A Model Context Protocol server for GitLab integration",
"main": "dist/index.js",
"type": "module",
Expand Down
157 changes: 156 additions & 1 deletion src/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
formatMilestonesResponse,
formatProtectedBranchesResponse,
formatUsersResponse,
formatGroupsResponse
formatGroupsResponse,
formatDiscussionsResponse
} from './formatters.js';

describe('formatPipelinesResponse', () => {
Expand Down Expand Up @@ -328,6 +329,160 @@ describe('formatUsersResponse', () => {
});
});

describe('formatDiscussionsResponse', () => {
const author = {
id: 1,
name: 'Reviewer',
username: 'rev',
avatar_url: null,
web_url: 'https://gitlab.example.com/rev'
};
const resolver = {
id: 2,
name: 'Maintainer',
username: 'maint',
avatar_url: null,
web_url: 'https://gitlab.example.com/maint'
};
const baseNote = {
attachment: null,
author,
system: false,
noteable_id: 1,
noteable_type: 'MergeRequest',
};

it('formats discussions and exposes per-note resolution fields', () => {
const response = formatDiscussionsResponse({
count: 1,
items: [
{
id: 'd1',
individual_note: false,
notes: [
{
...baseNote,
id: 10,
body: 'Please rename this',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
resolvable: true,
resolved: true,
resolved_by: resolver,
resolved_at: '2024-01-02T00:00:00Z',
}
]
}
]
});

const items = JSON.parse(response.content[1].text);
expect(items[0].resolvable).toBe(true);
expect(items[0].resolved).toBe(true);
expect(items[0].notes[0].resolved).toBe(true);
expect(items[0].notes[0].resolved_by).toEqual({ name: 'Maintainer', username: 'maint' });
expect(items[0].notes[0].resolved_at).toBe('2024-01-02T00:00:00Z');
});

it('aggregates thread.resolved=false when any resolvable note is unresolved', () => {
const response = formatDiscussionsResponse({
count: 1,
items: [
{
id: 'd1',
individual_note: false,
notes: [
{
...baseNote,
id: 10,
body: 'first',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
resolvable: true,
resolved: true,
resolved_by: resolver,
resolved_at: '2024-01-02T00:00:00Z',
},
{
...baseNote,
id: 11,
body: 'reply',
created_at: '2024-01-03T00:00:00Z',
updated_at: '2024-01-03T00:00:00Z',
resolvable: true,
resolved: false,
resolved_by: null,
resolved_at: null,
}
]
}
]
});

const items = JSON.parse(response.content[1].text);
expect(items[0].resolvable).toBe(true);
expect(items[0].resolved).toBe(false);
});

it('reports unresolved/resolved counts in the summary', () => {
const response = formatDiscussionsResponse({
count: 3,
items: [
{
id: 'd1',
individual_note: false,
notes: [{
...baseNote, id: 10, body: 'open', created_at: 't', updated_at: 't',
resolvable: true, resolved: false, resolved_by: null, resolved_at: null,
}]
},
{
id: 'd2',
individual_note: false,
notes: [{
...baseNote, id: 11, body: 'done', created_at: 't', updated_at: 't',
resolvable: true, resolved: true, resolved_by: resolver, resolved_at: 't',
}]
},
{
id: 'd3',
individual_note: true,
notes: [{
...baseNote, id: 12, body: 'plain', created_at: 't', updated_at: 't',
}]
}
]
});

expect(response.content[0].text).toBe('Found 3 discussions (1 unresolved, 1 resolved threads)');
});

it('omits resolved aggregate on individual_note discussions (no resolvable notes)', () => {
const response = formatDiscussionsResponse({
count: 1,
items: [
{
id: 'd1',
individual_note: true,
notes: [{
...baseNote,
id: 10,
body: 'just a comment',
created_at: 't',
updated_at: 't',
}]
}
]
});

const items = JSON.parse(response.content[1].text);
expect(items[0].resolvable).toBe(false);
expect(items[0].resolved).toBeUndefined();
// Without any resolvable threads, the summary stays the legacy short form.
expect(response.content[0].text).toBe('Found 1 discussions');
});
});

describe('formatGroupsResponse', () => {
it('formats groups with summary', () => {
const response = formatGroupsResponse({
Expand Down
62 changes: 44 additions & 18 deletions src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,26 +327,52 @@ export function formatNotesResponse(notes: GitLabNotesResponse) {
* @returns A formatted response object for the MCP tool
*/
export function formatDiscussionsResponse(discussions: GitLabDiscussionsResponse) {
// Create a summary of the discussions
const summary = `Found ${discussions.count} discussions`;
// Compute resolution stats so callers can see at a glance how many threads
// are still open. Individual notes (not part of a resolvable thread) are
// skipped — only resolvable discussions count toward resolved/unresolved.
const resolvableThreads = discussions.items.filter(d =>
d.notes.some(n => n.resolvable)
);
const unresolvedCount = resolvableThreads.filter(d =>
d.notes.some(n => n.resolvable && n.resolved === false)
).length;
const resolvedCount = resolvableThreads.length - unresolvedCount;
const summary = resolvableThreads.length > 0
? `Found ${discussions.count} discussions (${unresolvedCount} unresolved, ${resolvedCount} resolved threads)`
: `Found ${discussions.count} discussions`;
Comment on lines +333 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for calculating unresolvedCount is inconsistent with how the discussion-level resolved flag is computed later in the function (lines 347-349). Specifically, unresolvedCount uses n.resolved === false, while the discussion-level resolved flag uses n.resolved === true in an every check. If a resolvable note has resolved: undefined (which is allowed by the schema), the thread will be marked as resolved: false in the output but counted as "resolved" in the summary stats. Using a consistent check ensures the summary counts match the item flags.

Suggested change
const resolvableThreads = discussions.items.filter(d =>
d.notes.some(n => n.resolvable)
);
const unresolvedCount = resolvableThreads.filter(d =>
d.notes.some(n => n.resolvable && n.resolved === false)
).length;
const resolvedCount = resolvableThreads.length - unresolvedCount;
const summary = resolvableThreads.length > 0
? `Found ${discussions.count} discussions (${unresolvedCount} unresolved, ${resolvedCount} resolved threads)`
: `Found ${discussions.count} discussions`;
const resolvableThreads = discussions.items.filter(d =>
d.notes.some(n => n.resolvable)
);
const unresolvedCount = resolvableThreads.filter(d =>
d.notes.some(n => n.resolvable && n.resolved !== true)
).length;
const resolvedCount = resolvableThreads.length - unresolvedCount;
const summary = resolvableThreads.length > 0
? `Found ${discussions.count} discussions (${unresolvedCount} unresolved, ${resolvedCount} resolved threads)`
: `Found ${discussions.count} discussions`;


// Format the discussions data
const formattedDiscussions = discussions.items.map(discussion => ({
id: discussion.id,
individual_note: discussion.individual_note,
notes: discussion.notes.map(note => ({
id: note.id,
body: note.body,
author: {
name: note.author.name,
username: note.author.username
},
created_at: note.created_at,
updated_at: note.updated_at,
system: note.system,
type: note.type || (note.system ? "system" : "comment")
}))
}));
const formattedDiscussions = discussions.items.map(discussion => {
const resolvable = discussion.notes.some(n => n.resolvable);
const resolved = resolvable
? discussion.notes.every(n => !n.resolvable || n.resolved === true)
: undefined;
return {
id: discussion.id,
individual_note: discussion.individual_note,
resolvable,
resolved,
notes: discussion.notes.map(note => ({
id: note.id,
body: note.body,
author: {
name: note.author.name,
username: note.author.username
},
created_at: note.created_at,
updated_at: note.updated_at,
system: note.system,
type: note.type || (note.system ? "system" : "comment"),
resolvable: note.resolvable,
resolved: note.resolved,
resolved_by: note.resolved_by ? {
name: note.resolved_by.name,
username: note.resolved_by.username
} : undefined,
resolved_at: note.resolved_at ?? undefined
}))
};
});

// Return the formatted response
return {
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ const ALL_TOOLS = [
},
{
name: "list_merge_request_discussions",
description: "List all discussions (threaded comments) on a merge request",
description: "List discussions (threaded comments) on a merge request. Pass unresolved_only=true to filter to threads that still have at least one unresolved note.",
inputSchema: createJsonSchema(ListMergeRequestDiscussionsSchema),
readOnly: true
},
Expand Down Expand Up @@ -1322,6 +1322,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}
);

if (args.unresolved_only) {
const filteredItems = discussions.items.filter(d =>
d.notes.some(n => n.resolvable && n.resolved === false)
);
Comment on lines +1326 to +1328

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The filtering logic here should be consistent with the definition of a resolved thread used in the formatter. A thread is unresolved if at least one of its resolvable notes is not resolved. Using n.resolved === false might miss threads where the resolved field is missing or null on a resolvable note, even though the formatter would treat such a thread as unresolved.

          const filteredItems = discussions.items.filter(d =>
            d.notes.some(n => n.resolvable && n.resolved !== true)
          );

return formatDiscussionsResponse({
count: filteredItems.length,
items: filteredItems
});
}

return formatDiscussionsResponse(discussions);
}

Expand Down
Loading