Add API to list pipelines triggered by the current user
What does this MR do and why?
Adds an instance-level GET /pipelines REST API endpoint that lists CI pipelines triggered by the authenticated user across all projects, with source, created_after, and created_before filters. Results are limited to pipelines in the most recent CI partitions (that is, recently created pipelines), and pagination is keyset-only.
Today pipelines can only be listed per project (GET /projects/:id/pipelines), so there is no way to answer "which pipelines did I trigger today?" without iterating over every project a user has access to. This endpoint mirrors the existing instance-level GET /merge_requests endpoint.
Implementation notes:
- New
Ci::PipelinesForUserFinderscoped touser_id = current_user.id. Child pipelines are excluded by default (same behavior as the project-scoped endpoint) unlesssource=parent_pipelineis passed. - The finder scopes queries to
Ci::Partition.recent_ids(Ci::Pipeline.for_user(user).in_partition(Ci::Partition.recent_ids)), so lookups prune to the most recentp_ci_pipelinespartitions instead of scanning every partition. p_ci_pipelineslives on the CI database, so project visibility cannot be enforced with a join against main-database tables (project_authorizations). Instead, the paginated page is post-filtered throughCi::PipelinesForUserFinder.visible_to(pipelines, user), which wraps thePreloaders::ProjectPolicyPreloaderbatch preload andAbility.pipelines_readable_by_user— the same postfiltering patternGET /merge_requestsuses (API::Helpers::Authz::PostfilteringHelpers). Pipelines in projects the user can no longer read are redacted from the response.- Pagination is keyset-only, following the same pattern as
GET /projects/:id/packages/:package_id/pipelines.Ci::Pipeline.supported_keyset_orderingsdeclares{ created_at: [:desc] }; the endpoint acceptsorder_by(created_at, default),sort(desc, default),cursor, andper_page(1-100, default 20).pageis not a declared param and is ignored. Responses carryLink(rel="next") andX-Next-Cursor/X-Prev-Cursorheaders instead ofX-Total/X-Total-Pages/X-Page, and there is no count query. - Granular token support: the endpoint declares
permissions: :read_pipeline, boundary_type: :user, and theread_pipelineassignable permission gains theuserboundary (additive, mirroringread_merge_request). - New
Ci::Pipeline.with_api_entity_associationsscope preloadspipeline_metadataand project routes to avoid N+1 queries (covered by a QueryRecorder spec). doc/auth/tokens/fine_grained_access_tokens_rest.mdanddoc/api/openapi/openapi_v3.yamlchanges are generated (gitlab:permissions:routes:compile_docs,gitlab:openapi:v3:generate).
Database review
No schema changes. All queries are served by the existing p_ci_pipelines_user_id_created_at_source_idx index on (user_id, created_at, source); source != 12 is the default child-pipeline exclusion, and source is part of the index.
Two properties bound every query:
- The finder restricts all queries to the most recent CI partitions (
Ci::Partition.recent_ids), so the planner prunes to 3 of the table's 14 partitions. - Pagination is keyset-only. Both order columns are declared
NOT NULLin the keyset order, so the cursor condition is a composite row comparison —("created_at", "id") < (x, y)— which the planner uses as an index boundary (created_at <= xappears in theIndex Cond), not as a per-row filter. Rows with aNULLcreated_atare excluded (they can't be cursor-paginated; in practice Rails always setscreated_at).
LIMIT 21 is per_page (default 20) plus one lookahead row used to detect whether a next page exists. The SQL below uses the production partition ids at the time of testing (Ci::Partition.recent_ids = 114, 113, 112).
Queries
First page, default filters:
SELECT "p_ci_pipelines".* FROM "p_ci_pipelines" WHERE "p_ci_pipelines"."user_id" = 1614863 AND "p_ci_pipelines"."partition_id" IN (114, 113, 112) AND "p_ci_pipelines"."source" != 12 AND "p_ci_pipelines"."created_at" IS NOT NULL ORDER BY "p_ci_pipelines"."created_at" DESC, "p_ci_pipelines"."id" DESC LIMIT 21Cursor page:
SELECT "p_ci_pipelines".* FROM "p_ci_pipelines" WHERE "p_ci_pipelines"."user_id" = 1614863 AND "p_ci_pipelines"."partition_id" IN (114, 113, 112) AND "p_ci_pipelines"."source" != 12 AND "p_ci_pipelines"."created_at" IS NOT NULL AND (("p_ci_pipelines"."created_at", "p_ci_pipelines"."id") < ('2026-08-20 00:00:00', 2100000000)) ORDER BY "p_ci_pipelines"."created_at" DESC, "p_ci_pipelines"."id" DESC LIMIT 21First page with created_after:
SELECT "p_ci_pipelines".* FROM "p_ci_pipelines" WHERE "p_ci_pipelines"."user_id" = 1614863 AND "p_ci_pipelines"."partition_id" IN (114, 113, 112) AND "p_ci_pipelines"."source" != 12 AND "p_ci_pipelines"."created_at" > '2026-08-20 00:00:00' AND "p_ci_pipelines"."created_at" IS NOT NULL ORDER BY "p_ci_pipelines"."created_at" DESC, "p_ci_pipelines"."id" DESC LIMIT 21First page with source=parent_pipeline:
SELECT "p_ci_pipelines".* FROM "p_ci_pipelines" WHERE "p_ci_pipelines"."user_id" = 1614863 AND "p_ci_pipelines"."partition_id" IN (114, 113, 112) AND "p_ci_pipelines"."source" = 12 AND "p_ci_pipelines"."created_at" IS NOT NULL ORDER BY "p_ci_pipelines"."created_at" DESC, "p_ci_pipelines"."id" DESC LIMIT 21Cursor page with created_after (the planner bounds the scan on both sides: created_at > <window> AND created_at <= <cursor> in the Index Cond):
SELECT "p_ci_pipelines".* FROM "p_ci_pipelines" WHERE "p_ci_pipelines"."user_id" = 1614863 AND "p_ci_pipelines"."partition_id" IN (114, 113, 112) AND "p_ci_pipelines"."source" != 12 AND "p_ci_pipelines"."created_at" > '2026-08-20 00:00:00' AND "p_ci_pipelines"."created_at" IS NOT NULL AND (("p_ci_pipelines"."created_at", "p_ci_pipelines"."id") < ('2026-08-24 00:00:00', 2100000000)) ORDER BY "p_ci_pipelines"."created_at" DESC, "p_ci_pipelines"."id" DESC LIMIT 21Database Lab plans
Measured 2026-08-27 through the postgres.ai Joe CLI against the gitlab-production-ci project, on a fresh thin clone (joe reset) so first runs are genuinely cold. Each query was run twice, cold then warm, sequentially in one session, so later cold runs can partially reuse blocks read by earlier shapes (visible in the cursor + created_after guideline row). Two users were tested for every shape:
- Guideline user
1614863(~262k non-child pipelines) - Heavy bot user
gitlab-bot/1786152(~2.5M non-child pipelines, present in all partitions)
| Shape | User | Cold (total / exec) | Warm (total / exec) | Cold buffer reads | Joe commands (cold, warm) |
|---|---|---|---|---|---|
| First page | guideline | 11.3 ms / 4.2 ms | 7.2 ms / 0.22 ms | 52 (~416 KiB) | 488715, 488717 |
| First page | gitlab-bot | 13.7 ms / 6.7 ms | 10.4 ms / 0.78 ms | 226 (~1.8 MiB) | 488719, 488721 |
| Cursor page | guideline | 8.0 ms / 0.30 ms | 8.1 ms / 0.18 ms | 1 (~8 KiB) | 488723, 488725 |
| Cursor page | gitlab-bot | 9.9 ms / 2.9 ms | 7.2 ms / 0.30 ms | 60 (~480 KiB) | 488727, 488729 |
First page + created_after |
guideline | 158.1 ms / 146.4 ms | 7.3 ms / 0.21 ms | 51 (~408 KiB) | 488738, 488740 |
First page + created_after |
gitlab-bot | 126.0 ms / 119.0 ms | 8.9 ms / 0.61 ms | 218 (~1.7 MiB) | 488744, 488746 |
First page + source=parent_pipeline |
guideline | 22.8 ms / 15.8 ms | 7.5 ms / 0.21 ms | 14 (~112 KiB) | 488748, 488750 |
First page + source=parent_pipeline |
gitlab-bot | 17.9 ms / 9.8 ms | 7.4 ms / 0.28 ms | 2 (~16 KiB) | 488754, 488756 |
Cursor page + created_after |
guideline | 7.7 ms / 0.18 ms | 8.8 ms / 0.21 ms | 0 | 488758, 488760 |
Cursor page + created_after |
gitlab-bot | 88.1 ms / 80.8 ms | 7.6 ms / 0.35 ms | 98 (~784 KiB) | 488762, 488766 |
Every shape is index-served: user_id and the created_at bounds appear in the Index Cond, and nothing is heap-filtered beyond the source/partition_id rechecks. Results are flat in user size, reading at most ~230 buffers cold. Planning time is ~7 ms across all shapes with the 3 pruned partitions; cold totals vary with thin-clone I/O latency (~1-3 ms per random read), and warm execution is sub-millisecond everywhere. The Joe command IDs identify each run in the Joe command history (the CLI doesn't expose per-command web links).
References
- Instance-level precedent:
GET /merge_requests(lib/api/merge_requests.rb), which uses the same user-boundary authorization and postfiltering approach.
How to set up and validate locally
-
Trigger a few pipelines as your user in different projects.
-
Call the endpoint:
curl --request GET \ --header "PRIVATE-TOKEN: <your_access_token>" \ --url "http://gdk.test:3000/api/v4/pipelines?created_after=2026-08-18T00:00:00Z" -
Verify only pipelines you triggered are returned, newest first, and that pipelines from projects you cannot access are absent.
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.
Fixes #292949