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.Missingruns one membership query scoped bynamespace_id(single hash partition) and returns the submitted ids that do not belong, deduplicated, in first-seen submitted order. Nosoft_deleted_atfilter: 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
parseCanonicalUUIDpath guard the resolution route applies, and the response contract below. - Transport:
WriteErrorWithDetailsadds an optional structureddetailsslot undererror; the plainWriteErrorpath emits nodetailskey, 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.detailsstays an untyped object; the typedrepository_idsshape lives on a dedicatedVerificationFailureschema 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 msTimings: planning 1.094ms, execution 0.040ms, total 1.134ms.
Closes #181 (closed)