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 Diff connection in the schema, and #373205 belongs to groupcode review — a maintainer from that group should review the connection design. The field is marked experiment so its shape can still change.

Design notes
  • Pagination. DiffsResolver wraps MergeRequestDiff#paginated_diffs (the same Kaminari path the REST endpoint uses) in a Gitlab::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, matching discussionsWithActivity on this same type. Without it the field was bounded by the DiffCollection safe limits (100 files / 5000 lines / 512 KB) with no way to page past them and no way to detect truncation, since DiffType exposes neither overflow? nor real_size.

  • expanded: true and 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. The full_patch MCP 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-file too_large limit (applied at persist time) still drops oversized single files, and the worst-case payload is bounded by per_page (≤100) × FieldCallCount (≤10). Open question for groupcode review: should expanded instead be an opt-in field argument defaulting to false, so the general field keeps the conventional collapse behaviour and only the MCP query opts in?

  • FieldCallCount is retained and is not a per-diff cap. It bounds how many times the field resolves in one request (for example mergeRequests(first: 50) { nodes { diffs } }), which is the Gitaly N+1 guard. gitlab-org/gitlab#591232 is the customer-facing incident this prevents: diffStatsSummary hit Gitaly per merge request in a batched query and caused RequestDeadlineExceeded. #603402 reaches for the same pattern. The description inherited from Commit.diffs conflated the two limits and is corrected here.

  • No new type. Types::DiffType already exposes diff (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 pattern Types::Repositories::CommitType#diffs uses. 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 by read_merge_request, which MergeRequestType already authorizes (type-level authorize + authorize_granular_token). No new permission definition is required.

  • AI/Duo context exclusion is deliberately NOT applied here. Ai::FileExclusionService filtering 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 applies filter_diffs_for_mcp.

References

Screenshots or screen recordings

No UI changes.

How to set up and validate locally

  1. 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 }
          }
        }
      }
    }
  2. Confirm each node includes the raw patch text in diff, and that large/binary files come back with collapsed/tooLarge set instead of truncated content.
  3. Pass the returned endCursor back as diffs(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.

Edited by Terri Chu

Merge request reports

Loading
Loading