Add diffs field with patch text to MergeRequest type
What does this MR do and why?
Adds a diffs field to the MergeRequest GraphQL type that returns per-file diffs
including the raw patch text. MergeRequestType previously exposed only diff statistics
(diffStats, diffStatsSummary) and refs, so the REST diffs endpoint was the only way to read
an MR's patch text over the API. The field reuses the existing Types::DiffType and is a
forward-only connection paginated the same way the REST diffs endpoint is, so large merge
requests are reachable a page at a time rather than silently truncated.
Part A of the diffs work tracked by #605878 (closed). Unblocks the full_patch detail on
the get_merge_request MCP tool (stacked MR !249009 (merged)).
This is the first
Diffconnection in the schema, and #373205 belongs to groupcode review — a maintainer from that group should review the connection design. The field is markedexperimentso its shape can still change.
Design notes
-
Pagination.
DiffsResolverwrapsMergeRequestDiff#paginated_diffs(the same Kaminari path the REST endpoint uses) in aGitlab::Graphql::ExternallyPaginatedArray. The cursor is the page number, encoded as an opaque token through the schema's cursor encoder (context.schema.cursor_encoder), matching the convention every other externally-paginated resolver follows. Page size defaults to 20, capped at 100. Forward-only, matchingdiscussionsWithActivityon this same type. Without it the field was bounded by theDiffCollectionsafe limits (100 files / 5000 lines / 512 KB) with no way to page past them and no way to detect truncation, sinceDiffTypeexposes neitheroverflow?norreal_size. -
expanded: trueand diff size. The resolver requests uncollapsed diffs, so each page returns full patch text for every file rather than letting the collection-level soft caps (safe_max_lines/safe_max_bytes) collapse files partway through a page. Thefull_patchMCP consumer paginates explicitly and has no expand-on-demand path, so mid-page collapsing would hand the caller truncated patches it cannot recover. This removes only the soft collection cap: the hard per-filetoo_largelimit (applied at persist time) still drops oversized single files, and the worst-case payload is bounded byper_page(≤100) ×FieldCallCount(≤10). Open question for groupcode review: shouldexpandedinstead be an opt-in field argument defaulting tofalse, so the general field keeps the conventional collapse behaviour and only the MCP query opts in? -
FieldCallCountis retained and is not a per-diff cap. It bounds how many times the field resolves in one request (for examplemergeRequests(first: 50) { nodes { diffs } }), which is the Gitaly N+1 guard. gitlab-org/gitlab#591232 is the customer-facing incident this prevents:diffStatsSummaryhit Gitaly per merge request in a batched query and causedRequestDeadlineExceeded. #603402 reaches for the same pattern. The description inherited fromCommit.diffsconflated the two limits and is corrected here. -
No new type.
Types::DiffTypealready exposesdiff(raw patch string),newPath,oldPath,aMode,bMode,newFile,renamedFile,deletedFile,collapsed,tooLarge. It is an unauthorized value type reached only through an authorized parent — the same patternTypes::Repositories::CommitType#diffsuses. The new field mirrors that field exactly (object.diffs.diffs→Gitlab::Git::DiffCollection). -
Authorization parity. The REST endpoint enforces
read_merge_request_diff; that is implied byread_merge_request, whichMergeRequestTypealready authorizes (type-levelauthorize+authorize_granular_token). No new permission definition is required. -
AI/Duo context exclusion is deliberately NOT applied here.
Ai::FileExclusionServicefiltering is a consumer-context concern (only MCP/Duo callers must hide excluded files); baking it into a general GraphQL field would change behaviour for every consumer. The stacked MR applies it at the MCP tool layer, mirroring where the REST path appliesfilter_diffs_for_mcp.
References
- MCP tool issue: Add `diffs` include facet to get_merge_request ... (#611499 - closed)
- Closes #373205 (open since 2022, customer-requested, groupcode review)
- Also addresses #280803 (closed) (
oldPath/newPathunavailable fromdiffStatsalone) - Parent issue: #605878 (closed)
- Parent MR (base): !248373 (merged)
- Reuses
Types::DiffType, introduced forCommit.diffsin !113259 (merged)
Screenshots or screen recordings
No UI changes.
How to set up and validate locally
- In a Rails console or GraphiQL (
/-/graphql-explorer), query an MR you can read:query { project(fullPath: "gitlab-org/gitlab") { mergeRequest(iid: "1") { diffs(first: 5) { nodes { oldPath newPath newFile collapsed tooLarge diff } pageInfo { hasNextPage endCursor } } } } } - Confirm each node includes the raw patch text in
diff, and that large/binary files come back withcollapsed/tooLargeset instead of truncated content. - Pass the returned
endCursorback asdiffs(first: 5, after: "...")on a merge request with more than five files and confirm you get the next page. The cursor is an opaque token (e.g.endCursor: "Mg"), not a plain page number.
first page graphQL request/response (25-file MR)
query {
project(fullPath: "gitlab-org/mcp-testing") {
mergeRequest(iid: "3") {
diffs(first: 5) {
nodes { oldPath newPath newFile collapsed tooLarge diff }
pageInfo { hasNextPage endCursor }
}
}
}
}Returns files 01–05 with raw patch text, pageInfo.hasNextPage: true, and an opaque
pageInfo.endCursor ("Mg").
second page graphQL request/response
query {
project(fullPath: "gitlab-org/mcp-testing") {
mergeRequest(iid: "3") {
diffs(first: 5, after: "Mg") {
nodes { oldPath newPath newFile collapsed tooLarge diff }
pageInfo { hasNextPage endCursor }
}
}
}
}Returns files 06–10, pageInfo.hasNextPage: true, and pageInfo.endCursor: "Mw".
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.