feat: Display related source/target merge requests as a "stack" UI component

What does this MR do and why?

Provides model methods, GraphQL fields, Vue/Apollo component/queries to support a new "Stack" widget in the merge request view.

The stacked patch/diff/MR workflow is a well known way of managing and reviewing small changes that build upon each other. GitLab CLI supports this workflow, but the GitLab UI does not currently provide information about the relationships between merge requests when they target one another.

See https://docs.gitlab.com/user/project/merge_requests/stacked_diffs/

This is meant to improve the usability of the GitLab UI for folks that use a stacked MR workflow.

References

See cli#7632 See cli#7473

Screenshots or screen recordings

Screenshot_from_2025-03-13_16-49-03

How to set up and validate locally

  1. Spin up your development environment with this MR branch checked out.
  2. Create multiple merge requests within one of the test projects.
  3. Edit their source/target branches to create a chain (i.e. MR1 targets MR2, MR2 targets MR3, etc.). Alternatively, use glab stack to create the MRs.
  4. Navigate to MR2 and you should see the new "Stack" widget displaying both its source and target MRs.

Performance

I analyzed the recursive CTE queries using our production database and an existing "stacked" MR. Note that the cost values can be pretty deceiving on recursive CTEs (inner plan nodes don't necessarily know if they will be used, etc.), so I performed ANALYZE to see real execution time as well as the plan.

irb(main):008:0" MergeRequest.connection.execute(<<~SQL)
irb(main):025:0" EXPLAIN ANALYZE WITH RECURSIVE "stacked_merge_requests" AS (
irb(main):026:0"   SELECT "merge_requests".*
irb(main):027:0"   FROM "merge_requests"
irb(main):028:0"   WHERE "merge_requests"."source_branch" = 'review/refactor-llb-only-6506'
irb(main):029:0"   AND "merge_requests"."source_project_id" = 9
irb(main):030:0" 
irb(main):031:0"   UNION ALL
irb(main):032:0" 
irb(main):033:0"   SELECT "merge_requests".*
irb(main):034:0"   FROM "merge_requests"
irb(main):035:0"   INNER JOIN "stacked_merge_requests" ON "merge_requests"."source_branch" = "stacked_merge_requests"."target_branch"
irb(main):036:0"   AND "merge_requests"."source_project_id" = "stacked_merge_requests"."target_project_id"
irb(main):037:0" )
irb(main):038:0" SELECT * FROM stacked_merge_requests;
irb(main):039:0> SQL
=> #<PG::Result:0x00007fa4c54e2a98 status=PGRES_TUPLES_OK ntuples=11 nfields=1 cmd_tuples=0>
irb(main):040:0> puts result.values
CTE Scan on stacked_merge_requests  (cost=438.02..438.24 rows=11 width=663) (actual time=0.053..0.145 rows=4 loops=1)
  CTE stacked_merge_requests
    ->  Recursive Union  (cost=0.29..438.02 rows=11 width=1150) (actual time=0.046..0.125 rows=4 loops=1)
          ->  Index Scan using index_merge_requests_on_source_project_id_and_source_branch on merge_requests  (cost=0.29..4.30 rows=1 width=1150) (actual time=0.039..0.041 rows=1 loops=1)
                Index Cond: ((source_project_id = 9) AND ((source_branch)::text = 'review/refactor-llb-only-6506'::text))
          ->  Nested Loop  (cost=0.29..43.35 rows=1 width=1150) (actual time=0.018..0.021 rows=1 loops=3)
                ->  WorkTable Scan on stacked_merge_requests stacked_merge_requests_1  (cost=0.00..0.20 rows=10 width=36) (actual time=0.001..0.001 rows=1 loops=3)
                ->  Index Scan using index_merge_requests_on_source_project_id_and_source_branch on merge_requests merge_requests_1  (cost=0.29..4.30 rows=1 width=1150) (actual time=0.010..0.011 rows=1 loops=4)
                      Index Cond: ((source_project_id = stacked_merge_requests_1.target_project_id) AND ((source_branch)::text = (stacked_merge_requests_1.target_branch)::text))
Planning Time: 2.360 ms
Execution Time: 0.274 ms
=> nil

This is the query for MergeRequest#target_merge_requests. It seems very well optimized since it can make full use of the index_merge_requests_on_source_project_id_and_source_branch index.

irb(main):007:0" puts MergeRequest.connection.execute(<<~SQL).values
irb(main):008:0" EXPLAIN ANALYZE WITH RECURSIVE "stacked_merge_requests" AS (
irb(main):009:0"   SELECT "merge_requests".*
irb(main):010:0"   FROM "merge_requests"
irb(main):011:0"   WHERE "merge_requests"."target_branch" = 'review/refactor-llb-only-08fd'
irb(main):012:0"   AND "merge_requests"."target_project_id" = 9
irb(main):013:0" 
irb(main):014:0"   UNION ALL
irb(main):015:0" 
irb(main):016:0"   SELECT "merge_requests".*
irb(main):017:0"   FROM "merge_requests"
irb(main):018:0"   INNER JOIN "stacked_merge_requests" ON "merge_requests"."target_branch" = "stacked_merge_requests"."source_branch"
irb(main):019:0"   AND "merge_requests"."target_project_id" = "stacked_merge_requests"."source_project_id"
irb(main):020:0" )
irb(main):021:0" SELECT * FROM stacked_merge_requests;
irb(main):022:0> SQL
CTE Scan on stacked_merge_requests  (cost=752.08..752.30 rows=11 width=663) (actual time=0.095..0.186 rows=1 loops=1)
  CTE stacked_merge_requests
    ->  Recursive Union  (cost=0.29..752.08 rows=11 width=1154) (actual time=0.088..0.178 rows=1 loops=1)
          ->  Index Scan using index_merge_requests_on_target_branch on merge_requests  (cost=0.29..4.31 rows=1 width=1154) (actual time=0.077..0.079 rows=1 loops=1)
                Index Cond: ((target_branch)::text = 'review/refactor-llb-only-08fd'::text)
                Filter: (target_project_id = 9)
          ->  Nested Loop  (cost=5.43..74.75 rows=1 width=1154) (actual time=0.085..0.087 rows=0 loops=1)
                ->  WorkTable Scan on stacked_merge_requests stacked_merge_requests_1  (cost=0.00..0.20 rows=10 width=36) (actual time=0.002..0.002 rows=1 loops=1)
                ->  Bitmap Heap Scan on merge_requests merge_requests_1  (cost=5.43..7.45 rows=1 width=1154) (actual time=0.077..0.078 rows=0 loops=1)
                      Recheck Cond: ((target_project_id = stacked_merge_requests_1.source_project_id) AND ((target_branch)::text = (stacked_merge_requests_1.source_branch)::text))
                      ->  BitmapAnd  (cost=5.43..5.43 rows=1 width=0) (actual time=0.070..0.071 rows=0 loops=1)
                            ->  Bitmap Index Scan on index_merge_requests_on_target_project_id_and_merged_commit_sha  (cost=0.00..2.46 rows=23 width=0) (actual time=0.051..0.051 rows=122 loops=1)
                                  Index Cond: (target_project_id = stacked_merge_requests_1.source_project_id)
                            ->  Bitmap Index Scan on index_merge_requests_on_target_branch  (cost=0.00..2.72 rows=85 width=0) (actual time=0.005..0.005 rows=0 loops=1)
                                  Index Cond: ((target_branch)::text = (stacked_merge_requests_1.source_branch)::text)
Planning Time: 2.286 ms
Execution Time: 0.446 ms

This is the query for MergeRequest#source_merge_requests. It's not quite as optimal since target branches are not unique per project (so there's no project_id_target_branch index). There is however, an index on target_branch alone. This results in an index scan with subsequent filter for the project_id. In a real world scenario with stacked merge requests (such as via the glab stack CLI), branch names are quite unique, so perhaps this isn't a problem. That said, an additional index on (project_id, target_branch) could help.

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 Daniel Duvall

Merge request reports

Loading