Add GraphQL project fields for fork merge request detection

What does this MR do and why?

Adds two GraphQL fields to Types::ProjectType that the frontend needs to fix the fork "Create merge request" button. This is the backend half of a split; the frontend half is !255472 (merged).

Background. On a forked project, when there is an open merge request from the fork branch targeting the upstream project, the commits page incorrectly shows "Create merge request" instead of "View open merge request". The root cause is that project.mergeRequests in GraphQL is scoped to the target project (MergeRequest.of_projects filters on target_project_id), so fork-to-upstream MRs are invisible when querying the fork. There is currently no way to ask GraphQL "which merge requests originate from this project?".

Changes

  1. New sourcedMergeRequests field. A connection field backed by a thin resolver (Resolvers::Projects::SourcedMergeRequestsResolver). It returns merge requests whose source project is the given project, regardless of the target project — upstream, the fork itself, or anywhere else. Arguments: sourceBranches (required, validated to a maximum of 100 items) and state. Results are ordered by ID descending (newest first).

    The resolver is backed by MergeRequestsFinder (source_project_id, source_branch, state) rather than the raw Project#source_of_merge_requests association. The finder filters to merge requests targeting projects the viewer can read at query time, so connection-level aggregates like count and totalTimeToMerge cannot leak merge requests targeting projects the user cannot see. Per-node read_merge_request redaction remains in place as defense in depth.

    sourceBranches is required so the query is always served by the existing index_merge_requests_on_source_project_id_and_source_branch index. An unfiltered "all sourced merge requests, ordered by ID" query has no supporting index and could be pathological on a large project, so this argument is deliberately not optional. Note that GraphQL's required: true on a list type still accepts an empty array []; passing one would cause MergeRequestsFinder to drop the branch predicate entirely and produce exactly that unindexed query. The resolver therefore overrides ready? and short-circuits an empty sourceBranches to an empty connection without touching the database — no error is raised, because an empty branch list can be a legitimate caller outcome. Making the argument optional later would be a non-breaking change if a use case needs it.

    N+1 protections: MergeRequests::LookAheadPreloads now also preloads web_path (the target project's routes); the resolver preloads target-project policy data (project_feature, project_namespace, group, namespace.owner) via before_connection_authorization so per-node authorization doesn't hit the database once per merge request. The same before_connection_authorization block also runs Preloaders::UserMaxAccessLevelInProjectsPreloader over the distinct target projects in the page. Per-node read_merge_request authorization looks up the viewer's max member access level, which is a request-store-cached MAX(access_level) aggregate rather than an association, so the association preload alone didn't cover it; without this preloader each distinct target project costs an extra query.

    The field is gated by read_project on the parent project. Includes a request spec (spec/requests/api/graphql/project/sourced_merge_requests_spec.rb) covering redaction, anonymous users, the granular-token (GPAT) shared example, aggregate-does-not-leak assertions (count and totalTimeToMerge exclude merge requests targeting an unreadable project), state: all handling, and a QueryRecorder N+1 test (control and measured runs use the same user, varying only the number of merge requests; the added merge requests include one targeting a private project the user is only a member of, so per-target-project access-level N+1s are caught — public targets grant read through visibility and never reach that check, so they cannot detect this class of regression). A separate spec asserts that sourceBranches: [] resolves to an empty connection without issuing any merge_requests query. The resolver spec is now static-only (type, arguments, required-ness); behavior is covered by the request spec.

  2. New mrDefaultTargetSelf field. Exposes the existing ProjectSetting#mr_default_target_self boolean. Clients can use this to mirror Project#default_merge_request_target without an extra REST call. It batch-loads ProjectSetting via BatchLoader::GraphQL, sharing the loader already used by pagesUseUniqueDomain, to avoid an N+1 query when the field is resolved across multiple projects (for example, in a group's projects connection). Includes type spec and regenerated docs.

Database

The resolver calls MergeRequestsFinder with source_project_id, source_branch (or multiple branches), and an optional state, ordered by id DESC. sourceBranches is a required argument (max 100 entries), so every query is index-backed; the hypothetical unbounded query performs badly (plan).

Generated SQL

Shape A — state: opened, first: 1 (connection fetches LIMIT 2):

SELECT "merge_requests".* FROM "merge_requests"
INNER JOIN "projects" ON "projects"."id" = "merge_requests"."target_project_id"
LEFT JOIN project_features ON projects.id = project_features.project_id
WHERE (NOT EXISTS (
        SELECT 1 FROM "banned_users"
        WHERE (banned_users.user_id = (merge_requests.author_id + 0))))
  AND (EXISTS (
        SELECT 1 FROM "project_authorizations"
        WHERE "project_authorizations"."user_id" = ?
          AND (project_authorizations.project_id = projects.id)
          AND (project_authorizations.access_level >= 20))
       OR projects.visibility_level IN (10, 20))
  AND ("project_features"."merge_requests_access_level" IS NULL
       OR "project_features"."merge_requests_access_level" IN (20, 30)
       OR ("project_features"."merge_requests_access_level" = 10
           AND EXISTS (
             SELECT 1 FROM "project_authorizations"
             WHERE "project_authorizations"."user_id" = ?
               AND (project_authorizations.project_id = project_features.project_id)
               AND (project_authorizations.access_level >= 20))))
  AND "merge_requests"."state_id" = 1
  AND "merge_requests"."source_branch" = ?
  AND "merge_requests"."source_project_id" = ?
ORDER BY "merge_requests"."id" DESC
LIMIT 2

Shape B — no state argument: identical except the state_id predicate is absent and LIMIT is 101 (default page size 100 + 1). With multiple sourceBranches the equality becomes source_branch IN (...). The anti-join on banned_users is present for signed-in non-admin users; admins skip it.

Query plans

Plans were run by the database reviewer against gitlab-org/gitlab (project 278964) on postgres.ai. The links require login; qualitative summaries are inline.

Shape Index used Verdict Plan
state: opened, LIMIT 2, no match idx_merge_requests_on_source_project_and_branch_state_opened (partial) Performs well link
state: opened, LIMIT 2, match exists idx_merge_requests_on_source_project_and_branch_state_opened (partial) Performs well link
No state argument, LIMIT 101 index_merge_requests_on_source_project_id_and_source_branch Performs well link
Other state value index_merge_requests_on_source_project_id_and_source_branch + state filter Acceptable link
No branch filter (hypothetical, LIMIT 101) — Bad; confirms sourceBranches must be required link

References

How to set up and validate locally

  1. In GDK, fork a project under a second user account, push a branch to the fork, and open a merge request from that branch targeting the upstream project.

  2. In GraphiQL (/-/graphql-explorer), query the fork. sourceBranches is required:

    {
      project(fullPath: "<fork-full-path>") {
        mrDefaultTargetSelf
        sourcedMergeRequests(state: opened, sourceBranches: ["<branch>"], first: 1) {
          nodes {
            webPath
          }
        }
      }
    }
  3. The fork-to-upstream merge request should be returned with its upstream webPath. Querying as a user who cannot read the upstream project should return no nodes.

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.

The resolver spec, the request spec (spec/requests/api/graphql/project/sourced_merge_requests_spec.rb, with QueryRecorder N+1 coverage), and the project type spec all pass. GraphQL reference docs and introspection schemas are regenerated within the same commit.

Edited by Chaoyue Zhao

Merge request reports

Loading
Loading