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::PipelinesForUserFinder scoped to user_id = current_user.id. Child pipelines are excluded by default (same behavior as the project-scoped endpoint) unless source=parent_pipeline is 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 recent p_ci_pipelines partitions instead of scanning every partition.
  • p_ci_pipelines lives 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 through Ci::PipelinesForUserFinder.visible_to(pipelines, user), which wraps the Preloaders::ProjectPolicyPreloader batch preload and Ability.pipelines_readable_by_user — the same postfiltering pattern GET /merge_requests uses (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_orderings declares { created_at: [:desc] }; the endpoint accepts order_by (created_at, default), sort (desc, default), cursor, and per_page (1-100, default 20). page is not a declared param and is ignored. Responses carry Link (rel="next") and X-Next-Cursor/X-Prev-Cursor headers instead of X-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 the read_pipeline assignable permission gains the user boundary (additive, mirroring read_merge_request).
  • New Ci::Pipeline.with_api_entity_associations scope preloads pipeline_metadata and project routes to avoid N+1 queries (covered by a QueryRecorder spec).
  • doc/auth/tokens/fine_grained_access_tokens_rest.md and doc/api/openapi/openapi_v3.yaml changes 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 NULL in 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 <= x appears in the Index Cond), not as a per-row filter. Rows with a NULL created_at are excluded (they can't be cursor-paginated; in practice Rails always sets created_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 21

Cursor 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 21

First 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 21

First 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 21

Cursor 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 21

Database 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

  1. Trigger a few pipelines as your user in different projects.

  2. 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"
  3. 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

Edited by Marcel van Remmerden

Merge request reports

Loading
Loading