193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
package main
|
|
|
|
// gitlab.go — GitLab PAT management and project listing.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Settings keys and GitLab defaults.
|
|
const (
|
|
settingGitLabToken string = "gitlab_pat"
|
|
projectsPerPage int = 50
|
|
maxRepoPages int = 5 // pagination cap: 5 pages / 250 repos
|
|
gitlabTimeout time.Duration = 15 * time.Second
|
|
)
|
|
|
|
// GitLabError marks upstream GitLab failures (mapped to 502 by the API).
|
|
type GitLabError struct{ msg string }
|
|
|
|
func (e *GitLabError) Error() string { return e.msg }
|
|
|
|
var errNotConnected = errors.New("gitlab not connected")
|
|
|
|
// GitLabRepo is the /api/gitlab/repos item shape (protocol field names).
|
|
type GitLabRepo struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
LastActivityAt string `json:"lastActivityAt"`
|
|
WebURL string `json:"webUrl"`
|
|
DefaultBranch string `json:"defaultBranch"`
|
|
}
|
|
|
|
// giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we
|
|
// map from.
|
|
type giteaRepo struct {
|
|
FullName string `json:"full_name"`
|
|
Name string `json:"name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
HTMLURL string `json:"html_url"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
|
|
// GitLab stores/validates the PAT in the settings table and lists projects.
|
|
type GitLab struct {
|
|
store *Store
|
|
baseURL string
|
|
insecure bool
|
|
}
|
|
|
|
func NewGitLab(store *Store, baseURL string) *GitLab {
|
|
return &GitLab{store: store, baseURL: strings.TrimRight(baseURL, "/")}
|
|
}
|
|
|
|
// NewGitLabWithClient is the test seam: baseURL + transport overrides.
|
|
func NewGitLabWithClient(store *Store, baseURL string, insecureSkipVerify bool) *GitLab {
|
|
return &GitLab{store: store, baseURL: strings.TrimRight(baseURL, "/"), insecure: insecureSkipVerify}
|
|
}
|
|
|
|
func (g *GitLab) httpClient() *http.Client {
|
|
client := &http.Client{Timeout: gitlabTimeout}
|
|
if g.insecure {
|
|
client.Transport = &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test seam only
|
|
}
|
|
}
|
|
return client
|
|
}
|
|
|
|
// Status reports connection state; never includes the PAT.
|
|
func (g *GitLab) Status() map[string]any {
|
|
out := map[string]any{"connected": false, "baseUrl": g.baseURL}
|
|
token, ok, err := g.store.GetSetting(settingGitLabToken)
|
|
if err != nil || !ok || token == "" {
|
|
return out
|
|
}
|
|
out["connected"] = true
|
|
if username, ok, _ := g.store.GetSetting(settingGitLabUsername); ok {
|
|
out["username"] = username
|
|
}
|
|
return out
|
|
}
|
|
|
|
const settingGitLabUsername string = "gitlab_username"
|
|
|
|
// Connect validates the token against /api/v1/user and stores it.
|
|
func (g *GitLab) Connect(ctx context.Context, token string) (string, error) {
|
|
var user struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := g.do(ctx, "/api/v1/user", token, &user); err != nil {
|
|
return "", err
|
|
}
|
|
if user.Login == "" {
|
|
return "", &GitLabError{msg: "gitea returned no login for token"}
|
|
}
|
|
if err := g.store.SetSetting(settingGitLabToken, token); err != nil {
|
|
return "", err
|
|
}
|
|
if err := g.store.SetSetting(settingGitLabUsername, user.Login); err != nil {
|
|
return "", err
|
|
}
|
|
return user.Login, nil
|
|
}
|
|
|
|
// Disconnect drops the stored PAT.
|
|
func (g *GitLab) Disconnect() error {
|
|
if err := g.store.DeleteSetting(settingGitLabToken); err != nil {
|
|
return err
|
|
}
|
|
return g.store.DeleteSetting(settingGitLabUsername)
|
|
}
|
|
|
|
// token returns the stored PAT or errNotConnected.
|
|
func (g *GitLab) token() (string, error) {
|
|
token, ok, err := g.store.GetSetting(settingGitLabToken)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ok || token == "" {
|
|
return "", errNotConnected
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// Repos lists member projects sorted by most recent activity, paging
|
|
// upstream until a short page (capped at maxRepoPages).
|
|
func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
|
|
token, err := g.token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var repos []GitLabRepo
|
|
for page := 1; page <= maxRepoPages; page++ {
|
|
path := fmt.Sprintf("/api/v1/user/repos?limit=%d&page=%d", projectsPerPage, page)
|
|
var projects []giteaRepo
|
|
if err := g.do(ctx, path, token, &projects); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range projects {
|
|
if p.FullName == "" {
|
|
continue
|
|
}
|
|
repos = append(repos, GitLabRepo{
|
|
Path: p.FullName,
|
|
Name: p.Name,
|
|
Namespace: p.Owner.Login,
|
|
LastActivityAt: p.UpdatedAt,
|
|
WebURL: p.HTMLURL,
|
|
DefaultBranch: p.DefaultBranch,
|
|
})
|
|
}
|
|
if len(projects) < projectsPerPage {
|
|
break
|
|
}
|
|
}
|
|
sort.SliceStable(repos, func(i, j int) bool {
|
|
return repos[i].LastActivityAt > repos[j].LastActivityAt
|
|
})
|
|
return repos, nil
|
|
}
|
|
|
|
func (g *GitLab) do(ctx context.Context, path, token string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+token)
|
|
resp, err := g.httpClient().Do(req)
|
|
if err != nil {
|
|
return &GitLabError{msg: fmt.Sprintf("gitlab request failed: %v", err)}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return &GitLabError{msg: fmt.Sprintf("gitlab %s returned %d", path, resp.StatusCode)}
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return &GitLabError{msg: fmt.Sprintf("gitlab %s: decode: %v", path, err)}
|
|
}
|
|
return nil
|
|
}
|