Documentation
¶
Overview ¶
Package gitlab is the REAL GitLab REST v4 adapter for the P4-E1 walking skeleton (P4-E1-S10). It implements the forge.Forge port (ListBotThreads, CurrentHeads, CreateThread, ResolveThread, Approve, MergeCAS) plus the reads the `assent run` orchestration needs (GetMR, FileAtRef) against a live GitLab instance's `/api/v4` surface.
The exact API call shapes it mirrors are the ones the product-surface spike exercised against a booted GitLab (hack/spikes/e2e/smoke.sh): discussions → resolve → approve → merge?sha=. This adapter is the side-effecting write edge (it lives OUTSIDE internal/core and may use net/http); it stays FAIL-CLOSED — a 409/406 on the SHA-pinned merge maps to forge.ErrSHAMoved (no merge), and a target tip that moved since evaluation is rejected before the merge PUT.
AUTHOR-IDENTITY filter (ADR-0019): ListBotThreads returns a discussion as a bot thread ONLY when its first note's author username equals the configured botAuthor. A contributor (non-bot) note carrying a syntactically perfect marker is EXCLUDED — invisible to reconciliation — regardless of the marker's well-formedness. Filtering is by author identity, never by marker content.
SECRET REDACTION: the PAT is sent only as the PRIVATE-TOKEN request header. It is never logged, never placed in a URL, an error message, or a thread body.
Index ¶
- Variables
- func SyntheticDigest(source, target string) string
- type Client
- func (c *Client) Approve(project, mr string) (string, error)
- func (c *Client) CreateThread(project, mr string, marker forge.Marker, body string) (forge.Thread, error)
- func (c *Client) CurrentHeads(project, mr string) (source, target, digest string, err error)
- func (c *Client) FileAtRef(project, path, ref string) ([]byte, error)
- func (c *Client) GetMR(project, mr string) (MRInfo, error)
- func (c *Client) ListBotNotes(project, mr string) ([]forge.Note, error)
- func (c *Client) ListBotThreads(project, mr string) ([]forge.Thread, error)
- func (c *Client) MergeCAS(project, mr string, m forge.DesiredMerge) (string, error)
- func (c *Client) Resolve(req forge.ResolveRequest) (forge.ResolveResult, error)
- func (c *Client) ResolveThread(project, mr, id string) error
- func (c *Client) Snapshot(project, mr string) (forge.Snapshot, error)
- func (c *Client) UpsertComment(project, mr string, marker forge.Marker, body string) (forge.Note, error)
- func (c *Client) Warnings() []string
- type MRInfo
- type Option
- type RetryPolicy
Constants ¶
This section is empty.
Variables ¶
var ErrNotFound = forge.ErrNotFound
ErrNotFound is the typed error FileAtRef returns for a 404 (file absent at the ref). The caller decides what a missing file means (e.g. an absent governed file is a fail-safe REVIEW, not a crash).
AUD-S15 (ARCH-02): the SENTINEL now lives on the forge port as forge.ErrNotFound, so a caller can match an absent file without importing this adapter. This is the adapter's transitional alias — it IS the port sentinel, so errors.Is(err, forge.ErrNotFound) and errors.Is(err, gitlab.ErrNotFound) are the same question. Retire it when the GitHub adapter lands (E10).
ErrUnauthorized is the typed error returned on a 401/403 — e.g. an approval attempt with a token that GitLab forbids (an MR author may not approve their own MR; the caller supplies a different token). It is surfaced clearly so the caller does not mistake an authorization refusal for a transient error.
Functions ¶
func SyntheticDigest ¶
SyntheticDigest derives the non-empty merge-result digest the CAS pins carry from the source+target SHAs. It is deterministic and moves iff either head moves, so it is a genuine (belt-and-suspenders) drift signal on the digest axis — not a constant. It is NOT a real GitLab merge-result digest (GitLab exposes none); the DecisionRecord records that capability gap honestly.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a GitLab REST v4 adapter bound to one endpoint + PAT + bot identity. It implements forge.Forge. The zero value is not usable — construct with New.
func New ¶
New builds a GitLab adapter. endpoint is the instance base URL (e.g. https://gitlab.com); token is a PAT sent as the PRIVATE-TOKEN header; botAuthor is the username whose discussion first-notes count as bot-authored for the ADR-0019 author-identity filter. A trailing slash on endpoint is trimmed so path joins are unambiguous.
Without options the client ships the default bounded-retry policy (AUD-S11): idempotent GET/HEAD reads retry up to defaultMaxAttempts times on a transport error, a 429 or a 5xx, with jittered exponential backoff under a per-request deadline. Writes are NEVER auto-retried.
func (*Client) Approve ¶
Approve records an approval via POST /api/v4/projects/{project}/merge_requests/{mr}/approve and returns an approval id. GitLab forbids the MR author approving their own MR — the CALLER supplies a different token; this adapter just calls with whatever token it holds and surfaces a 401/403 as ErrUnauthorized. The returned id is the MR IID (GitLab's approve response carries no distinct approval id), so the receipt records a stable, non-empty approval target.
func (*Client) CreateThread ¶
func (c *Client) CreateThread(project, mr string, marker forge.Marker, body string) (forge.Thread, error)
CreateThread posts a new resolvable discussion whose body is the marker (hidden HTML comment) followed by the human body, and returns the created forge.Thread. POST /api/v4/projects/{project}/merge_requests/{mr}/discussions with a form-encoded `body`.
func (*Client) CurrentHeads ¶
CurrentHeads reads the MR's current source SHA, target-branch tip, and a SYNTHESISED merge-result digest.
GitLab (plain merge, no merge trains) exposes NO real merge-result digest, so there is nothing to read for that axis. The frozen forge.Reconcile CAS path nonetheless REQUIRES all three pins to be present and consistent (completeForMerge + the step-4 CurrentHeads pre-check compares the returned digest against DesiredMerge.MergeResultDigest). Returning "" for the digest would make the APPROVE path unreachable through Reconcile (either ErrIncompletePreconditions or a spurious ErrSHAMoved). So the adapter synthesises a NON-EMPTY digest deterministically from the source+target SHAs: it tracks head movement (if either head moves the digest moves too), which is exactly the belt-and-suspenders the three-pin contract asks for. The REAL merge protection is still the ?sha= PUT + target re-read in MergeCAS. The honest "gitlab has no merge-result digest" audit fact is recorded SEPARATELY in the DecisionRecord's capabilityGap by cmd/assent — never here.
func (*Client) FileAtRef ¶
FileAtRef returns the raw bytes of a file at a git ref via GET /api/v4/projects/{project}/repository/files/{urlencoded path}/raw?ref={ref}. A 404 maps to forge.ErrNotFound (the file is absent at that ref — the caller decides), wrapped with this adapter's own `gitlab: ` prefix so the rendered message names the forge that answered while errors.Is still reaches the neutral port sentinel. The path segment is percent-encoded because a governed file path contains slashes GitLab requires URL-encoded.
func (*Client) GetMR ¶
GetMR reads the MR metadata plus the target branch tip. It performs two GETs:
- GET /api/v4/projects/{project}/merge_requests/{mr} for source sha + source/target branch names;
- GET /api/v4/projects/{project}/repository/branches/{target_branch} for the target-branch tip (commit.id) — the value the SHA-guard pins the merge target to (NOT diff_refs.base_sha, which is the merge-base).
func (*Client) ListBotNotes ¶
ListBotNotes returns bot-authored MR notes filtered by AUTHOR IDENTITY (ADR-0019): a note counts iff its author username equals the configured botAuthor. It paginates the notes endpoint until the last page.
The loop is CAPPED at maxListPages (AUD-S10 / REL-03), FAIL-CLOSED for the same reason as ListBotThreads: UpsertComment reads this list to decide edit-in-place vs. create, so a silent partial would post a duplicate summary.
func (*Client) ListBotThreads ¶
ListBotThreads returns the bot-authored discussions on the MR as forge.Thread, filtered by AUTHOR IDENTITY (ADR-0019): a discussion is a bot thread iff its FIRST note's author username equals the configured botAuthor. A contributor note carrying a well-formed marker is EXCLUDED. It paginates the discussions endpoint until the last page. A discussion whose first note has no marker is skipped (not a finding thread in this slice).
The loop is CAPPED at maxListPages (AUD-S10 / REL-03) and the cap is FAIL-CLOSED: a paginator that never returns a short page yields an error, not a silent partial. An incomplete bot-thread list is the dangerous outcome — reconcile would read it as "that finding has no thread yet" and duplicate it.
func (*Client) MergeCAS ¶
MergeCAS performs the SHA-pinned compare-and-swap merge, fail-closed on both the source and target axes GitLab can honour:
- Re-read the current heads. If the TARGET tip has moved from the pinned target, return forge.ErrSHAMoved with NO merge — GitLab's ?sha= guards only the SOURCE head, so the adapter guards the target itself rather than merge an unevaluated target (ADR-0017 §1, keeping the three-pin contract as strong as GitLab allows). A source drift is also caught here early, and re-caught atomically by ?sha= below.
- PUT .../merge?sha={pinnedSource}. GitLab's ?sha= is a compare-and-swap on the SOURCE head: a moved source returns 409 (or 406) → forge.ErrSHAMoved, no merge. A 200 is the merge; any other non-200 is a generic error.
func (*Client) Resolve ¶
func (c *Client) Resolve(req forge.ResolveRequest) (forge.ResolveResult, error)
Resolve implements forge.Resolver per forge dossier §4 / P1-E3-S02: fetch approval rules/state, map eligible approvers, collect actual approvals, exclude MR author and bot client-side, pin SHAs on the evidence, and validate against the frozen ApprovalEvidence schema. Free/missing approval-rules → typed gap (never fabricated evidence). Stale evaluation pins → expired evidence (re-evaluate).
func (*Client) ResolveThread ¶
ResolveThread resolves a discussion in place via PUT /api/v4/projects/{project}/merge_requests/{mr}/discussions/{id}?resolved=true. It is idempotent: a 200 (resolved, or already-resolved) is success.
func (*Client) Snapshot ¶
Snapshot implements forge.Snapshotter. It reads MR heads (via GetMR semantics), changed-file paths from the MR diffs API (D-076), project merge settings and approval-rules presence for capability flags, and bot threads for reconciliation. Optional endpoints that return 404/403 fail safe to honest tier gaps — never invented Premium capabilities.
func (*Client) UpsertComment ¶
func (c *Client) UpsertComment(project, mr string, marker forge.Marker, body string) (forge.Note, error)
UpsertComment creates or edits-in-place exactly one summary-comment note on the MR. When a bot note with artifact.kind summary-comment already exists, it is updated via PUT; otherwise a new note is POSTed.
type MRInfo ¶
MRInfo is the merge-request metadata `assent run` pins its evaluation to.
AUD-S15 (ARCH-02): the TYPE now lives on the forge port as forge.MRInfo so a second adapter can satisfy the same read port; this is the adapter's transitional alias (a true Go type alias — gitlab.MRInfo and forge.MRInfo are one type, not two). For GitLab, TargetSHA is the target BRANCH TIP (commit.id from the branches endpoint), NOT diff_refs.base_sha (which is the merge-base — a different commit), and ForkMR means source_project_id != project_id. Retire the alias when the GitHub adapter lands (E10).
type Option ¶ added in v0.2.0
type Option func(*Client)
Option customises a Client at construction.
func WithContext ¶ added in v0.2.0
WithContext sets the parent context every request derives from. Cancelling it stops the client between attempts and fails the call closed.
func WithJitter ¶ added in v0.2.0
WithJitter injects the [0,1) jitter source used to spread the backoff window.
func WithRetry ¶ added in v0.2.0
func WithRetry(p RetryPolicy) Option
WithRetry replaces the whole retry policy. Zero-valued fields keep their default, so a caller can tune one axis without restating the rest.
func WithSleeper ¶ added in v0.2.0
WithSleeper injects the backoff sleeper.
type RetryPolicy ¶ added in v0.2.0
type RetryPolicy struct {
MaxAttempts int
BaseBackoff time.Duration
MaxBackoff time.Duration
RequestTimeout time.Duration
Sleep func(time.Duration)
Jitter func() float64 // returns [0,1)
}
RetryPolicy is the bounded retry/backoff configuration for idempotent reads. Sleep and Jitter are injected seams: tests supply a recording sleeper and a fixed jitter source so the backoff SCHEDULE is asserted deterministically, with no wall-clock or math/rand dependence in any assertion.