Skip to content

feat(mr): support viewing unresolved discussions on merge requests#98

Open
haifeiWu wants to merge 1 commit into
yoda-digital:mainfrom
haifeiWu:main
Open

feat(mr): support viewing unresolved discussions on merge requests#98
haifeiWu wants to merge 1 commit into
yoda-digital:mainfrom
haifeiWu:main

Conversation

@haifeiWu

Copy link
Copy Markdown

Summary

list_merge_request_discussions previously dropped GitLab's resolution metadata
(resolved, resolved_by, resolved_at) at the Zod boundary, leaving callers
with no way to tell which MR review threads were still open. This PR threads
that data all the way through — schema → formatter → tool output — and adds an
unresolved_only filter so callers can fetch just the threads still needing
attention. Fully additive: existing responses gain new optional fields, none
are removed or renamed.

Closes #

Type of change

  • feat — new functionality
  • test — adds or improves tests
  • chore — tooling, deps, infra

Pre-merge checklist

  • package.json version bumped — 0.8.1 → 0.9.0 (MINOR, new functionality)
  • CHANGELOG.md entry added under ## [Unreleased]
  • npm test passes (vitest — 96/96, +10 new)
  • npm run build passes
  • Conventional Commits format on commit subject — feat(mr): and chore(release):
  • No secrets, tokens, or .env values in the diff
  • Branch named feature/*, fix/*, docs/*, or refactor/* (committed directly on main in this fork; will need a feature branch if PR'd back upstream)
  • Touching src/index.tslist_merge_request_discussions retains readOnly: true; no impact on session lifecycle, token handling, or read-only mode

For breaking changes

Not applicable — fully additive change. The new fields on note objects and the
new optional argument on the tool are backwards-compatible with existing callers.

Notes for reviewer

What changed, file by file

  • src/schemas.tsGitLabNoteSchema gains resolved / resolved_by / resolved_at (all optional, with resolved_by reusing GitLabUserSchema and nullable). ListMergeRequestDiscussionsSchema gains
    optional unresolved_only: boolean.
  • src/formatters.tsformatDiscussionsResponse now emits per-note resolution fields, computes a thread-level resolvable/resolved aggregate, and appends (N unresolved, M resolved threads) to the summary
    line when any resolvable thread exists.
  • src/index.ts — handler for list_merge_request_discussions filters discussions by notes.some(n => n.resolvable && n.resolved === false) when unresolved_only=true. Tool description updated.
  • src/schemas.test.ts / src/formatters.test.ts — 10 new vitest cases.

Design choices to look at

  • Client-side filtering. The GitLab Discussions API has no server-side unresolved filter, so the handler fetches the requested page(s) first, then filters. This mirrors every other list tool in the server.
    For MRs with hundreds of discussions and aggressive pagination, callers should be aware they pay the full fetch cost.
  • Thread resolved semantics. A thread is reported as resolved only when all its resolvable notes are resolved (notes.every(n => !n.resolvable || n.resolved === true)), matching the GitLab UI definition.
    Mixed threads (some resolved, some not) report resolved=false.
  • Issue discussions are unaffected. Issue notes are never resolvable, so formatDiscussionsResponse falls through to the legacy short summary and the new fields surface as undefined for
    list_issue_discussions — no behavioral change for that tool.

Test coverage added

  • GitLabNoteSchema: unresolved resolvable note, resolved note with resolver metadata, note where the resolution fields are omitted entirely (issue-note path).
  • ListMergeRequestDiscussionsSchema: required fields, accepts unresolved_only: boolean, rejects non-boolean.
  • formatDiscussionsResponse: per-note resolution fields pass through; multi-note thread with one unresolved note aggregates to resolved=false; summary count line for mixed threads; individual-note path keeps
    the legacy short summary and emits no resolved aggregate.

- Extend GitLabNoteSchema with resolved / resolved_by / resolved_at so the
  resolution metadata GitLab returns for MR discussion notes is no longer
  silently dropped at the Zod boundary.
- Surface per-note and aggregated thread resolution status in
  formatDiscussionsResponse, and add an unresolved/resolved thread count
  to its summary line.
- Add unresolved_only filter to list_merge_request_discussions so callers
  can fetch only threads with at least one unresolved resolvable note;
  filtering is done client-side since the GitLab discussions API has no
  server-side equivalent.
- Cover the new schema + formatter behavior with vitest cases (96 tests
  total, +10 new).
@haifeiWu haifeiWu requested a review from nalyk as a code owner May 25, 2026 12:19

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several enhancements to the GitLab MCP server, primarily focusing on merge request discussions. Key changes include the addition of an unresolved_only filter for the list_merge_request_discussions tool, the inclusion of resolution metadata (such as resolved, resolved_by, and resolved_at) in discussion notes, and improved summary reporting for resolved versus unresolved threads. Feedback was provided regarding inconsistencies in how unresolved threads are identified; specifically, the logic should consistently treat any resolvable note that is not explicitly true as unresolved to ensure the summary statistics align with the item-level flags and the client-side filtering logic.

Comment thread src/formatters.ts
Comment on lines +333 to +342
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`;

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`;

Comment thread src/index.ts
Comment on lines +1326 to +1328
const filteredItems = discussions.items.filter(d =>
d.notes.some(n => n.resolvable && n.resolved === false)
);

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)
          );

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant