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 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. func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) { token, err := g.token() if err != nil { return nil, err } var projects []giteaRepo path := fmt.Sprintf("/api/v1/user/repos?limit=%d", projectsPerPage) if err := g.do(ctx, path, token, &projects); err != nil { return nil, err } repos := make([]GitLabRepo, 0, len(projects)) 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, }) } 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 }