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
-
New
sourcedMergeRequestsfield. 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) andstate. Results are ordered by ID descending (newest first).The resolver is backed by
MergeRequestsFinder(source_project_id,source_branch,state) rather than the rawProject#source_of_merge_requestsassociation. The finder filters to merge requests targeting projects the viewer can read at query time, so connection-level aggregates likecountandtotalTimeToMergecannot leak merge requests targeting projects the user cannot see. Per-noderead_merge_requestredaction remains in place as defense in depth.sourceBranchesis required so the query is always served by the existingindex_merge_requests_on_source_project_id_and_source_branchindex. 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'srequired: trueon a list type still accepts an empty array[]; passing one would causeMergeRequestsFinderto drop the branch predicate entirely and produce exactly that unindexed query. The resolver therefore overridesready?and short-circuits an emptysourceBranchesto 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::LookAheadPreloadsnow also preloadsweb_path(the target project's routes); the resolver preloads target-project policy data (project_feature,project_namespace,group,namespace.owner) viabefore_connection_authorizationso per-node authorization doesn't hit the database once per merge request. The samebefore_connection_authorizationblock also runsPreloaders::UserMaxAccessLevelInProjectsPreloaderover the distinct target projects in the page. Per-noderead_merge_requestauthorization looks up the viewer's max member access level, which is a request-store-cachedMAX(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_projecton 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 (countandtotalTimeToMergeexclude merge requests targeting an unreadable project),state: allhandling, and aQueryRecorderN+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 thatsourceBranches: []resolves to an empty connection without issuing anymerge_requestsquery. The resolver spec is now static-only (type, arguments, required-ness); behavior is covered by the request spec. -
New
mrDefaultTargetSelffield. Exposes the existingProjectSetting#mr_default_target_selfboolean. Clients can use this to mirrorProject#default_merge_request_targetwithout an extra REST call. It batch-loadsProjectSettingviaBatchLoader::GraphQL, sharing the loader already used bypagesUseUniqueDomain, 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 2Shape 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
- Frontend MR consuming these fields: !255472 (merged)
- Regression that surfaced the bug: !252846 (merged)
- Bug reported at: !252846 (comment 3822676573)
- Consistent test failure tracking: https://gitlab.com/gitlab-org/quality/test-failure-issues/-/work_items/44545
- Blocker for [Feature flag] clean up `project_commits_refactor` (#603171)
- Resolves #598206 (closed)
How to set up and validate locally
-
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.
-
In GraphiQL (
/-/graphql-explorer), query the fork.sourceBranchesis required:{ project(fullPath: "<fork-full-path>") { mrDefaultTargetSelf sourcedMergeRequests(state: opened, sourceBranches: ["<branch>"], first: 1) { nodes { webPath } } } } -
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.