Skip to content
Merged
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
17 changes: 15 additions & 2 deletions cmd/codemium/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,14 @@ func runAnalyze(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--group is required for gitlab")
}
baseURL := os.Getenv("CODEMIUM_GITLAB_URL")
prov = provider.NewGitLab(cred.AccessToken, baseURL, httpClient)
// Enable token refresh when token was sourced from glab CLI
var refreshFn func() (string, bool)
if os.Getenv("CODEMIUM_GITLAB_TOKEN") == "" {
if _, err := store.Load("gitlab"); err != nil {
refreshFn = auth.GlabCLIToken
}
}
prov = provider.NewGitLabWithRefresh(cred.AccessToken, baseURL, httpClient, refreshFn)
default:
return fmt.Errorf("unsupported provider: %s", providerName)
}
Expand Down Expand Up @@ -1093,7 +1100,13 @@ func runTrends(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--group is required for gitlab")
}
baseURL := os.Getenv("CODEMIUM_GITLAB_URL")
prov = provider.NewGitLab(cred.AccessToken, baseURL, httpClient)
var refreshFn func() (string, bool)
if os.Getenv("CODEMIUM_GITLAB_TOKEN") == "" {
if _, err := store.Load("gitlab"); err != nil {
refreshFn = auth.GlabCLIToken
}
}
prov = provider.NewGitLabWithRefresh(cred.AccessToken, baseURL, httpClient, refreshFn)
default:
return fmt.Errorf("unsupported provider: %s", providerName)
}
Expand Down
47 changes: 42 additions & 5 deletions internal/provider/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"

"github.com/dsablic/codemium/internal/model"
Expand All @@ -17,9 +18,11 @@ const gitlabAPIBase = "https://gitlab.com"

// GitLab implements Provider and CommitLister for GitLab.
type GitLab struct {
token string
baseURL string
client *http.Client
token string
baseURL string
client *http.Client
tokenRefresh func() (string, bool)
mu sync.Mutex
}

// NewGitLab creates a new GitLab provider. If baseURL is empty,
Expand All @@ -38,6 +41,14 @@ func NewGitLab(token string, baseURL string, client *http.Client) *GitLab {
}
}

// NewGitLabWithRefresh creates a GitLab provider with a token refresh callback.
// The refreshFn is called on 401 responses to obtain a fresh token (e.g. via glab CLI).
func NewGitLabWithRefresh(token string, baseURL string, client *http.Client, refreshFn func() (string, bool)) *GitLab {
g := NewGitLab(token, baseURL, client)
g.tokenRefresh = refreshFn
return g
}

type gitlabProject struct {
ID int `json:"id"`
Path string `json:"path"`
Expand Down Expand Up @@ -96,12 +107,38 @@ func (g *GitLab) ListRepos(ctx context.Context, opts ListOpts) ([]model.Repo, er
}

func (g *GitLab) doGet(ctx context.Context, reqURL string) (*http.Response, error) {
g.mu.Lock()
token := g.token
g.mu.Unlock()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+g.token)
return g.client.Do(req)
req.Header.Set("Authorization", "Bearer "+token)

resp, err := g.client.Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode == http.StatusUnauthorized && g.tokenRefresh != nil {
resp.Body.Close()
if newToken, ok := g.tokenRefresh(); ok && newToken != token {
g.mu.Lock()
g.token = newToken
g.mu.Unlock()

req2, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req2.Header.Set("Authorization", "Bearer "+newToken)
return g.client.Do(req2)
}
}

return resp, nil
}

func (g *GitLab) fetchPage(ctx context.Context, pageURL string) ([]model.Repo, string, error) {
Expand Down
81 changes: 81 additions & 0 deletions internal/provider/gitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,87 @@ func TestGitLabCommitStats(t *testing.T) {
}
}

func TestGitLabTokenRefreshOn401(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
auth := r.Header.Get("Authorization")
if auth == "Bearer expired-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if auth == "Bearer fresh-token" {
json.NewEncoder(w).Encode([]map[string]any{
{
"id": 1, "path": "repo-1", "path_with_namespace": "g/repo-1",
"name": "Repo 1", "web_url": "h", "http_url_to_repo": "c",
"default_branch": "main", "archived": false,
"forked_from_project": nil,
"namespace": map[string]any{"full_path": "g"},
},
})
return
}
t.Errorf("unexpected auth header: %s", auth)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()

refreshCalls := 0
refreshFn := func() (string, bool) {
refreshCalls++
return "fresh-token", true
}

gl := provider.NewGitLabWithRefresh("expired-token", server.URL, nil, refreshFn)
repos, err := gl.ListRepos(context.Background(), provider.ListOpts{Organization: "g"})
if err != nil {
t.Fatalf("expected successful retry, got error: %v", err)
}
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if refreshCalls != 1 {
t.Errorf("expected 1 refresh call, got %d", refreshCalls)
}
if calls != 2 {
t.Errorf("expected 2 server calls (401 + retry), got %d", calls)
}
}

func TestGitLabTokenRefreshNotCalledWithoutCallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()

gl := provider.NewGitLab("expired-token", server.URL, nil)
_, err := gl.ListRepos(context.Background(), provider.ListOpts{Organization: "g"})
if err == nil {
t.Fatal("expected error on 401 without refresh callback")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 in error, got: %v", err)
}
}

func TestGitLabTokenRefreshFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()

refreshFn := func() (string, bool) {
return "", false
}

gl := provider.NewGitLabWithRefresh("expired-token", server.URL, nil, refreshFn)
_, err := gl.ListRepos(context.Background(), provider.ListOpts{Organization: "g"})
if err == nil {
t.Fatal("expected error when refresh fails")
}
}

func TestGitLabListCommitsLimit(t *testing.T) {
page := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down