gitlab

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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).

View Source
var ErrUnauthorized = errors.New("gitlab: unauthorized (401/403)")

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

func SyntheticDigest(source, target string) string

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

func New(endpoint, token, botAuthor string, opts ...Option) *Client

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

func (c *Client) Approve(project, mr string) (string, error)

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

func (c *Client) CurrentHeads(project, mr string) (source, target, digest string, err error)

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

func (c *Client) FileAtRef(project, path, ref string) ([]byte, error)

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

func (c *Client) GetMR(project, mr string) (MRInfo, error)

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

func (c *Client) ListBotNotes(project, mr string) ([]forge.Note, error)

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

func (c *Client) ListBotThreads(project, mr string) ([]forge.Thread, error)

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

func (c *Client) MergeCAS(project, mr string, m forge.DesiredMerge) (string, error)

MergeCAS performs the SHA-pinned compare-and-swap merge, fail-closed on both the source and target axes GitLab can honour:

  1. 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.
  2. 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

func (c *Client) ResolveThread(project, mr, id string) error

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

func (c *Client) Snapshot(project, mr string) (forge.Snapshot, error)

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.

func (*Client) Warnings added in v0.2.0

func (c *Client) Warnings() []string

Warnings implements forge.Warner: the anomalies observed so far, deduplicated and SORTED so the PublicationReceipt is deterministic regardless of the order the forge returned its pages in.

type MRInfo

type MRInfo = forge.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

func WithContext(ctx context.Context) Option

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

func WithJitter(j func() float64) Option

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

func WithSleeper(sleep func(time.Duration)) Option

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL