feat(gitlabapi): add the batch repository verifications endpoint

Stacking

Was stacked on !1145 (merged) (the S33 spec + plan amendment recording this contract), which merged 2026-08-03; this MR now targets main directly. The auth team confirmed the contract on #181 (closed) 2026-07-31 (confirmation: #181 (comment 3626971317)).

What

POST /api/gitlab/v1/namespaces/{namespace_id}/repositories/verifications: the batch repository membership check the platform's authorization flows call before persisting repository-targeted bulk role grants (S33 Step 8, the last data-plane endpoint of the phase's GitLab API surface besides the condition endpoints).

  • Store: RepositoryStore.Missing runs one membership query scoped by namespace_id (single hash partition) and returns the submitted ids that do not belong, deduplicated, in first-seen submitted order. No soft_deleted_at filter: a grant persisted against a repository id survives that repository's soft-delete and restore, deliberately diverging from the sibling reads.
  • Handler: strict decode, batch bounds (1-1,000 per ADR-004, raw list length), the same parseCanonicalUUID path guard the resolution route applies, and the response contract below.
  • Transport: WriteErrorWithDetails adds an optional structured details slot under error; the plain WriteError path emits no details key, so existing envelope consumers see no shape change.

Contract

Status Meaning
204 every id belongs; no body
400 malformed request: bad JSON, unknown field, empty or oversized batch, a non-UUID value, or a non-canonical namespace_id spelling
422 well-formed batch, at least one id fails; error.details.repository_ids lists exactly the failing ids. The response never says why an id failed
404 namespace_id names no namespace
413 body over the service cap
500 store fault (logged, never a verdict)

The 400/422 split and the failing-id list were settled with the consuming auth flow on #181 (closed) (opacity prevented no enumeration a one-id batch did not already allow). The matching S33 spec amendment (AC 11, error-cases table, security section, resolutions log) and the plan Step 8 wording merged with !1145 (merged), as the Stacking section says; this MR carries no docs/ changes.

Notable for reviewers

  • The OpenAPI Error.details stays an untyped object; the typed repository_ids shape lives on a dedicated VerificationFailure schema referenced only by the 422, so generated clients do not see a verification-specific field on every error response.

Spec coverage

Spec: docs/specs/S33-gitlab-api.md

Spec item Tests
AC 11 204 all belong TestVerifyRepositories_AllIDsBelong_Returns204, TestRepositoryStore_Missing (integration), TestVerifications_ResponsesMatchOpenAPIContract
AC 11 422 failing ids listed TestVerifyRepositories_UnverifiedIDs_Returns422WithFailingIDs, TestVerifyRepositories_FailureMessage_IsStatic
AC 11 400 malformed batch TestVerifyRepositories_RejectedBatches_Return400, TestVerifyRepositories_FullBatch_IsAccepted (boundary)
E: non-canonical :uuid 400 TestVerifyRepositories_NonCanonicalNamespaceID_Returns400, TestContract_NamespaceIDParameter_MatchesHandlerGuard
E: unknown namespace 404 TestVerifyRepositories_NamespaceLookup_MapsOutcome
E: body over cap 413 TestVerifyRepositories_OversizedChunkedBody_Returns413, contract sweep 413 row
Set semantics / duplicates TestVerifyRepositories_RepeatedIDs_VerifiedAsSet, TestVerifyRepositories_RepeatedFullBatch_IsAccepted, TestMissingRepositoriesStmt, TestRepositoryStore_Missing duplicate rows
Soft-deleted verifies as belonging TestRepositoryStore_Missing_SoftDeletedStillBelongs (integration), TestMissingRepositoriesStmt (no soft_deleted_at in the rendered SQL)
Store fault 500, never a verdict TestVerifyRepositories_StoreError_Returns500
Bounds pinned to the document TestContract_VerifyRepositoriesRequestSchema_MatchesGoBounds
Details slot shape TestWriteErrorWithDetails_CarriesDetails, TestWriteError_OmitsDetailsKey

Size

~380 production Go (verifications handler 220, store 106, transport 36, wiring 19); ~1,470 test; plus 161 lines of OpenAPI.

E2E scenarios

No catalog update: the e2e catalogs cover client-facing format surfaces, while this endpoint is platform-internal (/api/gitlab/v1) and is covered in-repo by contract, unit, and DB-backed integration tests.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.Missing Bitmap Heap Scan repositories_p54_pkey 3 / 2 23.21 0.022ms 8 / 0 1
datastore.Missing

Summary: Plan matches the method's intent: a membership probe answered entirely by the primary key. The namespace_id literal prunes to one of 64 hash partitions, and the Bitmap Index Scan on that partition's PK covers both predicates; 2 of the 3 bound ids resolve, the third being a deliberate miss. The IN list is bounded by the handler's 1,000-id batch cap and id is a key column, so the result set cannot grow with the table. No anomalies.

Seed shape: namespaces=1, repositories=5000 (all repositories under the one namespace, so every row lands in the same hash partition)

Rendered SQL:

SELECT repositories.id AS "repositories.id"
FROM public.repositories
WHERE (repositories.namespace_id = $1::uuid) AND (repositories.id IN ($2::uuid, $3::uuid, $4::uuid));

Bound args: [abf406cb-2f2a-4072-820e-6d3b0937c96a, 725601c8-e1df-4a7f-ba0b-03df76589e2b, edb45293-86f7-40ae-a00f-172068ba9ef3, 00000000-0000-7000-8000-0000000000de] (namespace, two seeded ids, one deliberately missing id)

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Bitmap Heap Scan on repositories_p54 repositories  (cost=12.88..23.21 rows=3 width=16) (actual time=0.020..0.022 rows=2 loops=1)
   Recheck Cond: ((id = ANY ('{725601c8-e1df-4a7f-ba0b-03df76589e2b,edb45293-86f7-40ae-a00f-172068ba9ef3,00000000-0000-7000-8000-0000000000de}'::uuid[])) AND (namespace_id = 'abf406cb-2f2a-4072-820e-6d3b0937c96a'::uuid))
   Heap Blocks: exact=2
   Buffers: shared hit=8
   ->  Bitmap Index Scan on repositories_p54_pkey  (cost=0.00..12.88 rows=3 width=0) (actual time=0.015..0.015 rows=2 loops=1)
         Index Cond: ((id = ANY ('{725601c8-e1df-4a7f-ba0b-03df76589e2b,edb45293-86f7-40ae-a00f-172068ba9ef3,00000000-0000-7000-8000-0000000000de}'::uuid[])) AND (namespace_id = 'abf406cb-2f2a-4072-820e-6d3b0937c96a'::uuid))
         Buffers: shared hit=6
 Planning:
   Buffers: shared hit=342
 Planning Time: 1.094 ms
 Execution Time: 0.040 ms

Timings: planning 1.094ms, execution 0.040ms, total 1.134ms.

Closes #181 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading