Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions backend/internal/adapters/tracker/gitlab/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package gitlab

import (
"context"
"errors"
"io"
"os"
"os/exec"
"strings"
)

// TokenSource yields a GitLab personal-access (or project-access) token on
// demand. Mirrors the GitHub adapter's surface so daemon wiring is uniform
// across providers: the Tracker calls Token once at construction (fail-fast)
// and again per request (so rotated tokens are picked up without restart).
type TokenSource interface {
Token(ctx context.Context) (string, error)
}

// ErrNoToken is returned when no token source could yield a non-empty token.
var ErrNoToken = errors.New("gitlab tracker: no token configured")

// StaticTokenSource is a literal token, typically used in tests.
type StaticTokenSource string

func (s StaticTokenSource) Token(context.Context) (string, error) {
t := strings.TrimSpace(string(s))
if t == "" {
return "", ErrNoToken
}
return t, nil
}

// EnvTokenSource resolves a token from, in order:
//
// 1. The configured EnvVars (first non-empty wins).
// 2. GITLAB_TOKEN.
// 3. `glab auth token` (the GitLab CLI), if installed.
//
// The glab fallback is on by default — there is no opt-in flag. If glab is
// not installed, fails, or returns nothing, the source falls through silently
// to ErrNoToken; the exec error is NOT propagated, so the caller's failure
// mode is uniform ("no token configured") regardless of whether the user has
// glab installed. This matches the zero-friction directive AO-26: a user
// who has already done `glab auth login` gets working credentials with no
// extra setup, while CI environments using explicit env vars are unaffected.
//
// GLAB is the injection seam for tests. Production callers leave it nil and
// the source uses the real `glab auth token` exec; tests replace it with a
// fake to assert lookup order and contract behavior without touching $PATH.
// The signature mirrors the GitHub adapter's parallel `gh auth token` hook
// so both adapters' auth.go shapes stay parallel.
type EnvTokenSource struct {
EnvVars []string
GLAB func(ctx context.Context) (string, error)
}

func (s EnvTokenSource) Token(ctx context.Context) (string, error) {
for _, name := range s.EnvVars {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v, nil
}
}
if v := strings.TrimSpace(os.Getenv("GITLAB_TOKEN")); v != "" {
return v, nil
}
fn := s.GLAB
if fn == nil {
fn = defaultGlabToken
}
// Silent fallthrough: any error or empty output from glab maps to
// ErrNoToken. We intentionally do NOT surface the exec error — a missing
// CLI binary is the common case and shouldn't look like a configuration
// problem to the caller.
if tok, err := fn(ctx); err == nil {
if trimmed := strings.TrimSpace(tok); trimmed != "" {
return trimmed, nil
}
}
return "", ErrNoToken
}

// defaultGlabToken shells out to `glab auth token`. The ctx is threaded
// through so a caller wiring a startup deadline gets honored. Any error
// (binary missing, not logged in, glab unhappy for any reason) is returned
// for the caller to discard — see EnvTokenSource.Token's silent-fallthrough
// rule. Stderr is discarded so an unauthenticated glab doesn't print noise
// during a fallthrough that the caller has chosen to handle silently.
func defaultGlabToken(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "glab", "auth", "token")
cmd.Stderr = io.Discard
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
166 changes: 166 additions & 0 deletions backend/internal/adapters/tracker/gitlab/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package gitlab

import (
"context"
"errors"
"testing"
)

// TestEnvTokenSource_EnvVarWins pins that a configured env var, when present,
// short-circuits both GITLAB_TOKEN and the glab fallback. The glab fake is
// installed to prove it is NOT consulted on the happy path.
func TestEnvTokenSource_EnvVarWins(t *testing.T) {
t.Setenv("AO_GITLAB_TOKEN", "from-env")
t.Setenv("GITLAB_TOKEN", "from-default-env")
src := EnvTokenSource{
EnvVars: []string{"AO_GITLAB_TOKEN"},
GLAB: func(context.Context) (string, error) {
t.Fatalf("glab fallback called even though env var was set")
return "", nil
},
}
got, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if got != "from-env" {
t.Fatalf("token = %q, want from-env", got)
}
}

// TestEnvTokenSource_DefaultEnvWins pins that GITLAB_TOKEN is used when the
// configured EnvVars list is empty or yields nothing, before falling through
// to glab.
func TestEnvTokenSource_DefaultEnvWins(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "from-default-env")
src := EnvTokenSource{
GLAB: func(context.Context) (string, error) {
t.Fatalf("glab fallback called even though GITLAB_TOKEN was set")
return "", nil
},
}
got, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if got != "from-default-env" {
t.Fatalf("token = %q, want from-default-env", got)
}
}

// TestEnvTokenSource_GlabFallback pins that when no env var yields a token,
// the glab shell-out is used.
func TestEnvTokenSource_GlabFallback(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
var called bool
src := EnvTokenSource{
GLAB: func(context.Context) (string, error) {
called = true
return "glpat-from-glab", nil
},
}
got, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if !called {
t.Fatalf("glab fallback was not called")
}
if got != "glpat-from-glab" {
t.Fatalf("token = %q, want glpat-from-glab", got)
}
}

// TestEnvTokenSource_GlabFallbackTrimsWhitespace pins that the glab stdout is
// trimmed (the real CLI ends with a newline).
func TestEnvTokenSource_GlabFallbackTrimsWhitespace(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
src := EnvTokenSource{
GLAB: func(context.Context) (string, error) {
return " glpat-padded\n", nil
},
}
got, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if got != "glpat-padded" {
t.Fatalf("token = %q, want glpat-padded", got)
}
}

// TestEnvTokenSource_GlabFailureFallsThrough pins the silent-fallthrough
// contract: if glab errors (not installed, not logged in, etc.) the source
// returns ErrNoToken instead of propagating the exec error. This keeps the
// caller's failure mode uniform — "no token configured" — regardless of
// whether the user has glab installed.
func TestEnvTokenSource_GlabFailureFallsThrough(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
src := EnvTokenSource{
GLAB: func(context.Context) (string, error) {
return "", errors.New("exec: glab: executable file not found in $PATH")
},
}
_, err := src.Token(context.Background())
if !errors.Is(err, ErrNoToken) {
t.Fatalf("err = %v, want ErrNoToken (glab error should fall through silently)", err)
}
}

// TestEnvTokenSource_GlabEmptyFallsThrough pins that an empty (whitespace-only)
// glab output is treated as "no token" rather than as a valid token of zero
// length — otherwise StaticTokenSource-style downstream checks would yield
// "no token configured" inconsistently.
func TestEnvTokenSource_GlabEmptyFallsThrough(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
src := EnvTokenSource{
GLAB: func(context.Context) (string, error) {
return " \n", nil
},
}
_, err := src.Token(context.Background())
if !errors.Is(err, ErrNoToken) {
t.Fatalf("err = %v, want ErrNoToken (empty glab output should fall through)", err)
}
}

// TestEnvTokenSource_NilGlabUsesRealExec is a smoke test: with the GLAB field
// nil and no env vars, the source attempts a real exec. We don't depend on
// glab being installed on the test machine — we just assert that the
// fallthrough produces ErrNoToken (either because glab isn't installed OR
// because it has no token to give). Either way, the public contract holds:
// "no token configured" surfaces as ErrNoToken, never as a raw exec error.
func TestEnvTokenSource_NilGlabUsesRealExec(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
src := EnvTokenSource{} // GLAB nil → defaults to real exec.
// Either glab is installed and returns a real token (rare on CI), or it
// fails / is missing. In both failure modes we must see ErrNoToken; in
// the rare success case we get a non-empty token. Both are valid.
tok, err := src.Token(context.Background())
if err != nil && !errors.Is(err, ErrNoToken) {
t.Fatalf("err = %v, want ErrNoToken or a real token", err)
}
if err == nil && tok == "" {
t.Fatalf("got empty token with nil error")
}
}

// TestEnvTokenSource_ContextThreadedToGlab pins that the ctx is passed through
// to the GLAB hook. The github adapter's parallel work uses the same shape,
// so any future caller wiring a deadline at startup gets honored.
func TestEnvTokenSource_ContextThreadedToGlab(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
type ctxKey struct{}
parent := context.WithValue(context.Background(), ctxKey{}, "sentinel")
src := EnvTokenSource{
GLAB: func(ctx context.Context) (string, error) {
if v, _ := ctx.Value(ctxKey{}).(string); v != "sentinel" {
t.Fatalf("ctx not threaded through to GLAB hook: got %v", v)
}
return "tok", nil
},
}
if _, err := src.Token(parent); err != nil {
t.Fatalf("Token: %v", err)
}
}
74 changes: 74 additions & 0 deletions backend/internal/adapters/tracker/gitlab/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package gitlab implements the ports.Tracker outbound port for GitLab
// Issues over the REST v4 API. v1 is read-only:
//
// - Get returns a normalized snapshot of one issue (spawn-bootstrap reads
// it to hydrate the agent prompt).
// - List returns a filtered slice of issues in a project (one page, no
// auto-pagination in v1).
// - Preflight performs a single GET /user against GitLab to verify the
// token is accepted; success is cached for the lifetime of the Tracker,
// failures are not.
//
// Writing back to the tracker (Comment, Transition) is deferred to issue
// #40. The observer / polling loop is deferred to issue #35.
//
// # Authentication
//
// The adapter uses the PRIVATE-TOKEN header (not Authorization: Bearer)
// because that is GitLab's recommended path for personal-access and
// project-access tokens. The TokenSource interface lets callers inject a
// static token in tests or read GITLAB_TOKEN (plus arbitrary higher-priority
// env vars) in production.
//
// # ID and repo shape
//
// TrackerID.Native is "group/project#iid". Subgroup paths
// ("group/sub/project#iid", arbitrary depth) are accepted; the FULL project
// path is URL-encoded with url.PathEscape when forming the endpoint URL —
// without that, GitLab routes /projects/group/sub/project/... as a missing
// project rather than the nested one. TrackerRepo.Native is the same shape
// minus the "#iid".
//
// The IID is the project-internal sequential id (shown in the web UI as
// "#42"), not the global database id. That matches GitLab's recommendation
// to use IIDs in URLs.
//
// # Reverse state mapping
//
// GitLab Issues have two coarse states ("opened", "closed") and — unlike
// GitHub — do not expose a structured close_reason / state_reason on the
// REST v4 issue payload. The adapter projects them onto the normalized
// vocabulary as follows:
//
// - closed + label "cancelled" OR "wontfix" -> cancelled
// - closed (no cancelled/wontfix label) -> done
// - opened + label "in-review" -> review (wins when
// both status labels are present; the workflow is progress -> review)
// - opened + label "in-progress" -> in_progress
// - otherwise -> open
//
// The "in-progress" / "in-review" convention is borrowed verbatim from the
// GitHub adapter so a downstream consumer sees the same shape regardless of
// which tracker an issue lives in. The "cancelled" / "wontfix" labels are
// recognized because GitLab has no native equivalent of GitHub's
// state_reason=not_planned — humans (and other tooling) use one of these
// labels to mark issues abandoned rather than resolved. The adapter does
// NOT write any of these labels in v1 (issue #40).
//
// # Rate limiting
//
// GitLab uses RateLimit-Remaining / RateLimit-Reset (no X- prefix, unlike
// GitHub) and 429 (not 403) for rate-limit responses. On 429 the adapter
// returns a typed *RateLimitError matching errors.Is(err, ErrRateLimited);
// callers that want to back off intelligently can extract ResetAt /
// RetryAfter via errors.As.
//
// # Out of scope
//
// - No Comment, no Transition (issue #40).
// - No List pagination beyond a single page; callers needing more results
// need to wait for the observer/polling work in issue #35.
// - No webhook receiver, no polling goroutine, no fact projection into LCM.
// - No richer per-provider metadata on Issue (milestones, epics,
// iterations); the port only carries fields all v1 providers can fill.
package gitlab
Loading
Loading