diff --git a/backend/app/gitprovider/factory.go b/backend/app/gitprovider/factory.go index aa23854..cc2a7fb 100644 --- a/backend/app/gitprovider/factory.go +++ b/backend/app/gitprovider/factory.go @@ -19,6 +19,12 @@ func NewProvider(serverType, protocol, baseUrl, authToken string) (GitProvider, baseUrl: baseUrl, authToken: authToken, }, nil + case "gitlab": + return &GitLabProvider{ + protocol: protocol, + baseUrl: baseUrl, + authToken: authToken, + }, nil default: return nil, fmt.Errorf("unsupported git provider: %s", serverType) } diff --git a/backend/app/gitprovider/gitlab.go b/backend/app/gitprovider/gitlab.go new file mode 100644 index 0000000..70ee225 --- /dev/null +++ b/backend/app/gitprovider/gitlab.go @@ -0,0 +1,143 @@ +package gitprovider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +type GitLabProvider struct { + protocol string + baseUrl string + authToken string +} + +var _ GitProvider = (*GitLabProvider)(nil) + +func (p *GitLabProvider) FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error) { + apiUrl := fmt.Sprintf("%s://%s/api/v4/", p.protocol, strings.TrimSuffix(p.baseUrl, "/")) + + result, err := fetchGitLabBranchHead(apiUrl, owner, repo, branch, p.authToken) + if err != nil { + return nil, fmt.Errorf("fetching branch head: %w", err) + } + + if err = downloadAndExtractGitLab(apiUrl, owner, repo, branch, p.authToken, destPath); err != nil { + return nil, err + } + + return result, nil +} + +func fetchGitLabBranchHead(apiUrl, owner, repo, branch, token string) (*DeployResult, error) { + // GitLab identifies projects by URL-encoded "namespace/repo" path. + projectID := url.PathEscape(owner + "/" + repo) + encodedBranch := url.PathEscape(branch) + reqUrl := fmt.Sprintf("%sprojects/%s/repository/branches/%s", apiUrl, projectID, encodedBranch) + + req, err := http.NewRequest(http.MethodGet, reqUrl, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("PRIVATE-TOKEN", token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching branch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitLab returned %s for %s", resp.Status, reqUrl) + } + + var payload struct { + Commit struct { + ID string `json:"id"` + Title string `json:"title"` // first line of the commit message + } `json:"commit"` + } + if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding branch response: %w", err) + } + + return &DeployResult{ + CommitHash: payload.Commit.ID, + CommitMessage: payload.Commit.Title, + }, nil +} + +func downloadAndExtractGitLab(apiUrl, owner, repo, branch, token, destDir string) error { + // GET /projects/:id/repository/archive.zip?sha= + projectID := url.PathEscape(owner + "/" + repo) + archiveURL := fmt.Sprintf("%sprojects/%s/repository/archive.zip?sha=%s", apiUrl, projectID, url.QueryEscape(branch)) + + req, err := http.NewRequest(http.MethodGet, archiveURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("PRIVATE-TOKEN", token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitLab returned %s for %s", resp.Status, archiveURL) + } + + storageRoot := filepath.Dir(destDir) + + tmpZip, err := os.CreateTemp(storageRoot, "gitlabbranch-*.zip") + if err != nil { + return fmt.Errorf("creating temp zip: %w", err) + } + defer os.Remove(tmpZip.Name()) + + if _, err = io.Copy(tmpZip, resp.Body); err != nil { + tmpZip.Close() + return fmt.Errorf("writing zip: %w", err) + } + tmpZip.Close() + + tmpDir, err := os.MkdirTemp(storageRoot, "gitlabbranch-unpack-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err = unzip(tmpZip.Name(), tmpDir); err != nil { + return fmt.Errorf("unzipping: %w", err) + } + + // GitLab names the root folder {repo}-{branch}-{sha}/. + entries, err := os.ReadDir(tmpDir) + if err != nil { + return fmt.Errorf("reading temp dir: %w", err) + } + if len(entries) != 1 || !entries[0].IsDir() { + return fmt.Errorf("unexpected archive layout: expected a single root directory") + } + extractedRoot := filepath.Join(tmpDir, entries[0].Name()) + + if err = os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { + return fmt.Errorf("creating destination parent dirs: %w", err) + } + os.RemoveAll(destDir) + + if err = os.Rename(extractedRoot, destDir); err == nil { + return nil + } + + if err = copyDir(extractedRoot, destDir); err != nil { + return fmt.Errorf("cross-device copy to destination: %w", err) + } + return nil +}