package staticsite import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path/filepath" "strings" "time" ) // GitLabProvider implements GitProvider for GitLab repositories. type GitLabProvider struct { apiBase string // e.g., "https://gitlab.com/api/v4" rawBase string // e.g., "https://gitlab.com" token string httpClient *http.Client } // NewGitLabProvider creates a new GitLab provider. // baseURL should be "https://gitlab.com" or a self-hosted GitLab URL. func NewGitLabProvider(baseURL, token string) *GitLabProvider { base := strings.TrimRight(baseURL, "/") return &GitLabProvider{ apiBase: base + "/api/v4", rawBase: base, token: token, httpClient: &http.Client{ Timeout: 60 * time.Second, }, } } func (g *GitLabProvider) Name() string { return "gitlab" } // projectPath returns the URL-encoded project path (owner/repo → owner%2Frepo). func projectPath(owner, repo string) string { return url.PathEscape(owner + "/" + repo) } func (g *GitLabProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { var allRepos []RepoInfo page := 1 for { apiURL := fmt.Sprintf("%s/projects?membership=true&per_page=100&page=%d&order_by=last_activity_at", g.apiBase, page) if query != "" { apiURL += "&search=" + url.QueryEscape(query) } body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list repos: %w", err) } var projects []struct { PathWithNamespace string `json:"path_with_namespace"` Name string `json:"name"` Description string `json:"description"` Visibility string `json:"visibility"` WebURL string `json:"web_url"` Namespace struct { Path string `json:"path"` } `json:"namespace"` } if err := json.Unmarshal(body, &projects); err != nil { return nil, fmt.Errorf("decode repos: %w", err) } for _, p := range projects { allRepos = append(allRepos, RepoInfo{ Owner: p.Namespace.Path, Name: p.Name, FullName: p.PathWithNamespace, Description: p.Description, Private: p.Visibility != "public", HTMLURL: p.WebURL, }) } if len(projects) < 100 { break } page++ } return allRepos, nil } func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) error { apiURL := fmt.Sprintf("%s/projects/%s", g.apiBase, projectPath(owner, repo)) _, err := g.doGet(ctx, apiURL) return err } func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 for { apiURL := fmt.Sprintf("%s/projects/%s/repository/branches?per_page=100&page=%d", g.apiBase, projectPath(owner, repo), page) body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list branches: %w", err) } var branches []struct { Name string `json:"name"` } if err := json.Unmarshal(body, &branches); err != nil { return nil, fmt.Errorf("decode branches: %w", err) } for _, b := range branches { allBranches = append(allBranches, b.Name) } if len(branches) < 100 { break } page++ } return allBranches, nil } func (g *GitLabProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { apiURL := fmt.Sprintf("%s/projects/%s/repository/branches/%s", g.apiBase, projectPath(owner, repo), url.PathEscape(branch)) body, err := g.doGet(ctx, apiURL) if err != nil { return "", fmt.Errorf("get branch: %w", err) } var result struct { Commit struct { ID string `json:"id"` } `json:"commit"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("decode branch: %w", err) } return result.Commit.ID, nil } func (g *GitLabProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { var allEntries []FolderEntry page := 1 for { apiURL := fmt.Sprintf("%s/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", g.apiBase, projectPath(owner, repo), url.QueryEscape(branch), page) body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list tree: %w", err) } var entries []struct { Path string `json:"path"` Type string `json:"type"` // "blob" or "tree" } if err := json.Unmarshal(body, &entries); err != nil { return nil, fmt.Errorf("decode tree: %w", err) } for _, e := range entries { allEntries = append(allEntries, FolderEntry{ Path: e.Path, IsDir: e.Type == "tree", }) } if len(entries) < 100 { break } page++ } return allEntries, nil } func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { entries, err := g.ListTree(ctx, owner, repo, branch) if err != nil { return fmt.Errorf("list tree: %w", err) } folderPath = strings.TrimPrefix(folderPath, "/") folderPath = strings.TrimSuffix(folderPath, "/") prefix := folderPath + "/" for _, entry := range entries { if entry.IsDir { continue } if !strings.HasPrefix(entry.Path, prefix) { continue } relativePath := strings.TrimPrefix(entry.Path, prefix) localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) // GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path} fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s", g.rawBase, owner, repo, branch, entry.Path) if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { return fmt.Errorf("download %s: %w", relativePath, err) } } return nil } func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } g.setAuth(req) req.Header.Set("Accept", "application/json") resp, err := g.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } return body, nil } func (g *GitLabProvider) setAuth(req *http.Request) { if g.token != "" { req.Header.Set("PRIVATE-TOKEN", g.token) } }