diff --git a/AGENTS.md b/AGENTS.md index fb1e86d591..d0b8765977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Fullsend is a platform for fully autonomous agentic development for GitHub-hosted organizations. It contains design documents organized by problem domain (`docs/`) and a Go CLI (`cmd/fullsend/`) that manages GitHub App setup and org configuration. See [fullsend.sh](https://fullsend.sh) for the full documentation site. +Fullsend is a platform for fully autonomous agentic development for Git-hosted organizations (GitHub, GitLab, Forgejo). It contains design documents organized by problem domain (`docs/`) and a Go CLI (`cmd/fullsend/`) that manages forge setup and org configuration. See [fullsend.sh](https://fullsend.sh) for the full documentation site. ## How to work in this repo diff --git a/README.md b/README.md index ffdaaf40f1..feba3d21d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fullsend -Autonomous agentic software development for GitHub-hosted organizations. +Autonomous agentic software development for Git-hosted organizations (GitHub, GitLab, Forgejo). Fullsend agents triage issues, implement solutions, review code, and merge to production autonomously — while being secure by design. diff --git a/docs/guides/dev/behaviour-drivers.md b/docs/guides/dev/behaviour-drivers.md index 860b5d99d2..51d51dbe9e 100644 --- a/docs/guides/dev/behaviour-drivers.md +++ b/docs/guides/dev/behaviour-drivers.md @@ -52,7 +52,7 @@ Use `forge.Client` for operations it already exposes; add REST helpers inside th ## Step definitions -Steps must **not** import `internal/forge/github` directly — only drivers. This keeps scenarios vendor-agnostic. +Steps must **not** import forge-specific packages (`internal/forge/github`, `internal/forge/gitlab`) directly — only drivers. This keeps scenarios vendor-agnostic. Steps use `world.Install` for config repo paths (`ConfigOwner`, `ConfigRepo`, `ConfigPathPrefix`) instead of hardcoding the per-org `.fullsend` config repo. diff --git a/docs/guides/getting-started/getting-inference.md b/docs/guides/getting-started/getting-inference.md index 8114b98975..d1a2d9a0f8 100644 --- a/docs/guides/getting-started/getting-inference.md +++ b/docs/guides/getting-started/getting-inference.md @@ -60,7 +60,7 @@ for, and `` is your GCP project name. The output resembles: ```text ⚡ fullsend - Autonomous agentic development for GitHub organizations + Autonomous agentic development for Git-hosted organizations → Provisioning WIF for repo-scoped inference: / diff --git a/docs/guides/getting-started/org-mode.md b/docs/guides/getting-started/org-mode.md index 4b5e5e87d9..208825349b 100644 --- a/docs/guides/getting-started/org-mode.md +++ b/docs/guides/getting-started/org-mode.md @@ -33,7 +33,7 @@ Where `` is the GitHub organization and `` is your GCP project ```text ⚡ fullsend - Autonomous agentic development for GitHub organizations + Autonomous agentic development for Git-hosted organizations → Provisioning WIF for org-scoped inference: diff --git a/docs/vision.md b/docs/vision.md index 37a3b09634..de5c39eba8 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -49,7 +49,7 @@ Modern coding agents have largely solved the code generation problem. Given a we - **Authority and governance** — who decides what agents can do? - **Security** — how do we prevent the autonomous system from being exploited? -This project exists to explore these problems in a way that's applicable to any GitHub-hosted organization, though the patterns may need adaptation for specific organizational contexts. See [docs/problems/applied/](problems/applied/) for organization-specific considerations. +This project exists to explore these problems in a way that's applicable to any Git-hosted organization, though the patterns may need adaptation for specific organizational contexts. See [docs/problems/applied/](problems/applied/) for organization-specific considerations. ## Principles diff --git a/internal/cli/root.go b/internal/cli/root.go index 502d267f5b..58761c0828 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,8 +33,8 @@ func resolveUpstreamRef() (ref, tag string) { func newRootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "fullsend", - Short: "Autonomous agentic development for GitHub organizations", - Long: "fullsend automates the setup and management of agentic development pipelines for GitHub organizations.", + Short: "Autonomous agentic development for Git-hosted organizations", + Long: "fullsend automates the setup and management of agentic development pipelines for Git-hosted organizations.", SilenceUsage: true, SilenceErrors: true, Version: version, diff --git a/internal/forge/gitlab/ci.go b/internal/forge/gitlab/ci.go new file mode 100644 index 0000000000..78b0f015cb --- /dev/null +++ b/internal/forge/gitlab/ci.go @@ -0,0 +1,656 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +// GetAuthenticatedUser returns the username of the authenticated GitLab user. +func (c *LiveClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + resp, err := c.get(ctx, "/user") + if err != nil { + return "", fmt.Errorf("get authenticated user: %w", err) + } + var user struct { + Username string `json:"username"` + } + if err := decodeJSON(resp, &user); err != nil { + return "", fmt.Errorf("decode user: %w", err) + } + return user.Username, nil +} + +// GetAuthenticatedUserIdentity returns the display name and email of the +// authenticated GitLab user for Signed-off-by trailers. +// +// When name is empty, the username is used as a fallback. When email is +// empty, a noreply address is constructed from the user's ID and username +// to avoid producing malformed Signed-off-by trailers. +func (c *LiveClient) GetAuthenticatedUserIdentity(ctx context.Context) (*forge.UserIdentity, error) { + resp, err := c.get(ctx, "/user") + if err != nil { + return nil, fmt.Errorf("get user identity: %w", err) + } + var user struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + } + if err := decodeJSON(resp, &user); err != nil { + return nil, fmt.Errorf("decode user identity: %w", err) + } + + name := user.Name + if name == "" { + name = user.Username + } + email := user.Email + if email == "" { + host := "gitlab.com" + if u, err := url.Parse(c.baseURL); err == nil && u.Hostname() != "" { + host = u.Hostname() + } + email = fmt.Sprintf("%d+%s@users.noreply.%s", user.ID, user.Username, host) + } + + return &forge.UserIdentity{Name: name, Email: email}, nil +} + +// GetTokenScopes returns nil because GitLab does not expose token scopes +// via an API response header the way GitHub does. +func (c *LiveClient) GetTokenScopes(_ context.Context) ([]string, error) { + return nil, nil +} + +// IsInstallationToken returns false because GitLab has no App installation +// token concept. +func (c *LiveClient) IsInstallationToken(_ context.Context) (bool, error) { + return false, nil +} + +// --------------------------------------------------------------------------- +// Repo-level secrets (CI/CD variables with protected+masked flags) +// --------------------------------------------------------------------------- + +// CreateRepoSecret creates or updates a protected, masked CI/CD variable +// (secret). If the value doesn't meet GitLab's masking requirements (min +// 8 chars, single line, restricted charset), the variable is stored unmasked. +// If the variable already exists, it is updated in place. +func (c *LiveClient) CreateRepoSecret(ctx context.Context, owner, repo, name, value string) error { + basePath := fmt.Sprintf("/projects/%s/variables", projectPath(owner, repo)) + body := map[string]any{ + "key": name, + "value": value, + "protected": true, + "masked": true, + "variable_type": "env_var", + } + resp, err := c.post(ctx, basePath, body) + if err == nil { + resp.Body.Close() + return nil + } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + return fmt.Errorf("create repo secret %s: %w", name, err) + } + + if isMaskingError(apiErr) { + body["masked"] = false + resp, err = c.post(ctx, basePath, body) + if err == nil { + resp.Body.Close() + return nil + } + if !errors.As(err, &apiErr) { + return fmt.Errorf("create repo secret %s: %w", name, err) + } + } + + if isAlreadyExistsError(apiErr) { + return c.updateRepoSecret(ctx, owner, repo, name, value) + } + + return fmt.Errorf("create repo secret %s: %w", name, err) +} + +func (c *LiveClient) updateRepoSecret(ctx context.Context, owner, repo, name, value string) error { + updatePath := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + body := map[string]any{ + "value": value, + "protected": true, + "masked": true, + } + resp, err := c.put(ctx, updatePath, body) + if err == nil { + resp.Body.Close() + return nil + } + + var apiErr *APIError + if errors.As(err, &apiErr) && isMaskingError(apiErr) { + body["masked"] = false + resp, err = c.put(ctx, updatePath, body) + if err != nil { + return fmt.Errorf("update repo secret %s: %w", name, err) + } + resp.Body.Close() + return nil + } + return fmt.Errorf("update repo secret %s: %w", name, err) +} + +func isMaskingError(err *APIError) bool { + return err.StatusCode == http.StatusBadRequest && + strings.Contains(strings.ToLower(err.Message), "mask") +} + +func isAlreadyExistsError(err *APIError) bool { + if err.StatusCode == http.StatusConflict { + return true + } + return err.StatusCode == http.StatusBadRequest && + strings.Contains(strings.ToLower(err.Message), "has already been taken") +} + +// RepoSecretExists checks whether a CI/CD variable (secret) exists. +func (c *LiveClient) RepoSecretExists(ctx context.Context, owner, repo, name string) (bool, error) { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return false, fmt.Errorf("check secret %s: %w", name, err) + } + + if resp.StatusCode == http.StatusOK { + resp.Body.Close() + return true, nil + } + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return false, nil + } + return false, checkStatus(resp, http.StatusOK) +} + +// DeleteRepoSecret deletes a CI/CD variable (secret). It is idempotent: +// a 404 (variable already gone) is not treated as an error. +func (c *LiveClient) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + resp, err := c.do(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("delete repo secret %s: %w", name, err) + } + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK || + resp.StatusCode == http.StatusAccepted || resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return nil + } + return checkStatus(resp, http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Repo-level variables (CI/CD variables) +// --------------------------------------------------------------------------- + +// CreateOrUpdateRepoVariable creates a CI/CD variable, or updates it if it +// already exists. GitLab returns either 409 Conflict or 400 Bad Request with +// "has already been taken" for duplicate keys. +func (c *LiveClient) CreateOrUpdateRepoVariable(ctx context.Context, owner, repo, name, value string) error { + basePath := fmt.Sprintf("/projects/%s/variables", projectPath(owner, repo)) + createBody := map[string]any{ + "key": name, + "value": value, + "variable_type": "env_var", + } + resp, err := c.post(ctx, basePath, createBody) + if err == nil { + resp.Body.Close() + return nil + } + + // If the variable already exists, update it. GitLab may return either + // 409 (ErrAlreadyExists) or 400 with "has already been taken". + alreadyExists := errors.Is(err, forge.ErrAlreadyExists) + if !alreadyExists { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 400 && + strings.Contains(strings.ToLower(apiErr.Message), "has already been taken") { + alreadyExists = true + } + } + if !alreadyExists { + return fmt.Errorf("create variable %s: %w", name, err) + } + + updatePath := fmt.Sprintf("%s/%s", basePath, url.PathEscape(name)) + updateBody := map[string]any{ + "value": value, + "variable_type": "env_var", + } + resp, err = c.put(ctx, updatePath, updateBody) + if err != nil { + return fmt.Errorf("update variable %s: %w", name, err) + } + resp.Body.Close() + return nil +} + +// RepoVariableExists checks whether a CI/CD variable exists. +func (c *LiveClient) RepoVariableExists(ctx context.Context, owner, repo, name string) (bool, error) { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return false, fmt.Errorf("check variable %s: %w", name, err) + } + + if resp.StatusCode == http.StatusOK { + resp.Body.Close() + return true, nil + } + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return false, nil + } + return false, checkStatus(resp, http.StatusOK) +} + +// GetRepoVariable returns the value of a CI/CD variable. +// Returns ("", false, nil) if the variable does not exist. +func (c *LiveClient) GetRepoVariable(ctx context.Context, owner, repo, name string) (string, bool, error) { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return "", false, fmt.Errorf("get variable %s: %w", name, err) + } + + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return "", false, nil + } + if err := checkStatus(resp, http.StatusOK); err != nil { + return "", false, fmt.Errorf("get variable %s: %w", name, err) + } + + var result struct { + Value string `json:"value"` + } + if err := decodeJSON(resp, &result); err != nil { + return "", false, fmt.Errorf("decode variable %s: %w", name, err) + } + return result.Value, true, nil +} + +// ListRepoVariables returns all CI/CD variables for a project as a +// key-to-value map. Results are paginated; the method follows pagination +// until all variables are fetched. +func (c *LiveClient) ListRepoVariables(ctx context.Context, owner, repo string) (map[string]string, error) { + const perPage = 100 + const maxPages = 100 + result := make(map[string]string) + + for page := 1; page <= maxPages; page++ { + path := fmt.Sprintf("/projects/%s/variables?per_page=%d&page=%d", projectPath(owner, repo), perPage, page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list repo variables page %d: %w", page, err) + } + + var vars []struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := decodeJSON(resp, &vars); err != nil { + return nil, fmt.Errorf("decode repo variables page %d: %w", page, err) + } + + for _, v := range vars { + result[v.Key] = v.Value + } + + if len(vars) < perPage { + return result, nil + } + } + + return nil, fmt.Errorf("list repo variables: pagination exceeded %d pages", maxPages) +} + +// DeleteRepoVariable deletes a CI/CD variable. It is idempotent: +// a 404 (variable already gone) is not treated as an error. +func (c *LiveClient) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + resp, err := c.do(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("delete repo variable %s: %w", name, err) + } + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK || + resp.StatusCode == http.StatusAccepted || resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return nil + } + return checkStatus(resp, http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Org-level secrets — not supported (GitLab per-repo mode) +// --------------------------------------------------------------------------- + +// CreateOrgSecret is not supported on GitLab (per-repo mode). +func (c *LiveClient) CreateOrgSecret(_ context.Context, _, _, _ string, _ []int64) error { + return forge.ErrNotSupported +} + +// OrgSecretExists is not supported on GitLab (per-repo mode). +func (c *LiveClient) OrgSecretExists(_ context.Context, _, _ string) (bool, error) { + return false, forge.ErrNotSupported +} + +// DeleteOrgSecret is not supported on GitLab (per-repo mode). +func (c *LiveClient) DeleteOrgSecret(_ context.Context, _, _ string) error { + return forge.ErrNotSupported +} + +// SetOrgSecretRepos is not supported on GitLab (per-repo mode). +func (c *LiveClient) SetOrgSecretRepos(_ context.Context, _, _ string, _ []int64) error { + return forge.ErrNotSupported +} + +// GetOrgSecretRepos is not supported on GitLab (per-repo mode). +func (c *LiveClient) GetOrgSecretRepos(_ context.Context, _, _ string) ([]int64, error) { + return nil, forge.ErrNotSupported +} + +// --------------------------------------------------------------------------- +// Org-level variables — not supported (GitLab per-repo mode) +// --------------------------------------------------------------------------- + +// CreateOrUpdateOrgVariable is not supported on GitLab (per-repo mode). +func (c *LiveClient) CreateOrUpdateOrgVariable(_ context.Context, _, _, _ string, _ []int64) error { + return forge.ErrNotSupported +} + +// CreateOrUpdateOrgVariableAll is not supported on GitLab (per-repo mode). +func (c *LiveClient) CreateOrUpdateOrgVariableAll(_ context.Context, _, _, _ string) error { + return forge.ErrNotSupported +} + +// OrgVariableExists is not supported on GitLab (per-repo mode). +func (c *LiveClient) OrgVariableExists(_ context.Context, _, _ string) (bool, error) { + return false, forge.ErrNotSupported +} + +// GetOrgVariable is not supported on GitLab (per-repo mode). +func (c *LiveClient) GetOrgVariable(_ context.Context, _, _ string) (string, bool, error) { + return "", false, forge.ErrNotSupported +} + +// ListOrgVariables is not supported on GitLab (per-repo mode). +func (c *LiveClient) ListOrgVariables(_ context.Context, _ string) ([]forge.OrgVariable, error) { + return nil, forge.ErrNotSupported +} + +// DeleteOrgVariable is not supported on GitLab (per-repo mode). +func (c *LiveClient) DeleteOrgVariable(_ context.Context, _, _ string) error { + return forge.ErrNotSupported +} + +// SetOrgVariableRepos is not supported on GitLab (per-repo mode). +func (c *LiveClient) SetOrgVariableRepos(_ context.Context, _, _ string, _ []int64) error { + return forge.ErrNotSupported +} + +// GetOrgVariableRepos is not supported on GitLab (per-repo mode). +func (c *LiveClient) GetOrgVariableRepos(_ context.Context, _, _ string) ([]int64, error) { + return nil, forge.ErrNotSupported +} + +// --------------------------------------------------------------------------- +// CI/Workflow operations — GitHub Actions concepts, not supported +// --------------------------------------------------------------------------- + +// GetWorkflow is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) GetWorkflow(_ context.Context, _, _, _ string) (*forge.Workflow, error) { + return nil, forge.ErrNotSupported +} + +// GetLatestWorkflowRun is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) GetLatestWorkflowRun(_ context.Context, _, _, _ string) (*forge.WorkflowRun, error) { + return nil, forge.ErrNotSupported +} + +// GetWorkflowRun is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) GetWorkflowRun(_ context.Context, _, _ string, _ int) (*forge.WorkflowRun, error) { + return nil, forge.ErrNotSupported +} + +// DispatchWorkflow is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) DispatchWorkflow(_ context.Context, _, _, _, _ string, _ map[string]string) error { + return forge.ErrNotSupported +} + +// ListWorkflowRuns is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) ListWorkflowRuns(_ context.Context, _, _, _ string) ([]forge.WorkflowRun, error) { + return nil, forge.ErrNotSupported +} + +// ListRecentWorkflowRuns is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) ListRecentWorkflowRuns(_ context.Context, _, _ string, _ int) ([]forge.WorkflowRun, error) { + return nil, forge.ErrNotSupported +} + +// ListWorkflowRunArtifacts is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) ListWorkflowRunArtifacts(_ context.Context, _, _ string, _ int) ([]forge.WorkflowArtifact, error) { + return nil, forge.ErrNotSupported +} + +// DownloadWorkflowRunArtifact is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) DownloadWorkflowRunArtifact(_ context.Context, _, _ string, _ int) ([]byte, error) { + return nil, forge.ErrNotSupported +} + +// ListRepositoryArtifacts is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) ListRepositoryArtifacts(_ context.Context, _, _ string, _ int) ([]forge.RepositoryArtifact, error) { + return nil, forge.ErrNotSupported +} + +// GetWorkflowRunLogs is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) GetWorkflowRunLogs(_ context.Context, _, _ string, _ int) (string, error) { + return "", forge.ErrNotSupported +} + +// GetWorkflowRunAnnotations is not supported on GitLab (GitHub Actions concept). +func (c *LiveClient) GetWorkflowRunAnnotations(_ context.Context, _, _ string, _ int) ([]forge.Annotation, error) { + return nil, forge.ErrNotSupported +} + +// --------------------------------------------------------------------------- +// Pipeline schedules (GitLab-native) +// --------------------------------------------------------------------------- + +// CreatePipelineSchedule creates a pipeline schedule and attaches variables. +// Returns the schedule ID. +func (c *LiveClient) CreatePipelineSchedule(ctx context.Context, owner, repo, ref, description, cron string, variables map[string]string) (int64, error) { + path := fmt.Sprintf("/projects/%s/pipeline_schedules", projectPath(owner, repo)) + body := map[string]string{ + "ref": ref, + "description": description, + "cron": cron, + "cron_timezone": "UTC", + } + resp, err := c.post(ctx, path, body) + if err != nil { + return 0, fmt.Errorf("create pipeline schedule: %w", err) + } + + var schedule struct { + ID int64 `json:"id"` + } + if err := decodeJSON(resp, &schedule); err != nil { + return 0, fmt.Errorf("decode pipeline schedule: %w", err) + } + + for key, value := range variables { + varPath := fmt.Sprintf("/projects/%s/pipeline_schedules/%d/variables", + projectPath(owner, repo), schedule.ID) + varBody := map[string]string{ + "key": key, + "value": value, + } + varResp, err := c.post(ctx, varPath, varBody) + if err != nil { + _ = c.DeletePipelineSchedule(ctx, owner, repo, schedule.ID) + return 0, fmt.Errorf("create pipeline schedule variable %s: %w", key, err) + } + varResp.Body.Close() + } + + return schedule.ID, nil +} + +// DeletePipelineSchedule deletes a pipeline schedule. +func (c *LiveClient) DeletePipelineSchedule(ctx context.Context, owner, repo string, scheduleID int64) error { + path := fmt.Sprintf("/projects/%s/pipeline_schedules/%d", projectPath(owner, repo), scheduleID) + return c.delete_(ctx, path) +} + +// ListPipelineSchedules returns all pipeline schedules for the project. +func (c *LiveClient) ListPipelineSchedules(ctx context.Context, owner, repo string) ([]forge.PipelineSchedule, error) { + proj := projectPath(owner, repo) + var result []forge.PipelineSchedule + + for page := 1; page <= 100; page++ { + path := fmt.Sprintf("/projects/%s/pipeline_schedules?per_page=100&page=%d", proj, page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list pipeline schedules page %d: %w", page, err) + } + + var schedules []struct { + ID int64 `json:"id"` + Description string `json:"description"` + Ref string `json:"ref"` + Cron string `json:"cron"` + CronTimezone string `json:"cron_timezone"` + Active bool `json:"active"` + } + if err := decodeJSON(resp, &schedules); err != nil { + return nil, fmt.Errorf("decode pipeline schedules page %d: %w", page, err) + } + + for _, s := range schedules { + result = append(result, forge.PipelineSchedule{ + ID: s.ID, + Description: s.Description, + Ref: s.Ref, + Cron: s.Cron, + CronTimezone: s.CronTimezone, + Active: s.Active, + }) + } + + if len(schedules) < 100 { + break + } + } + + return result, nil +} + +// --------------------------------------------------------------------------- +// CI variables (branch-restricted) +// --------------------------------------------------------------------------- + +// UpdateCIVariable updates an existing CI/CD variable's value and protected flag. +func (c *LiveClient) UpdateCIVariable(ctx context.Context, owner, repo, name, value string, protected bool) error { + path := fmt.Sprintf("/projects/%s/variables/%s", projectPath(owner, repo), url.PathEscape(name)) + body := map[string]any{ + "value": value, + "protected": protected, + } + resp, err := c.put(ctx, path, body) + if err != nil { + return fmt.Errorf("update CI variable %s: %w", name, err) + } + resp.Body.Close() + return nil +} + +// CreateProtectedCIVariable creates a branch-restricted, unmasked CI/CD variable. +// Values are visible in pipeline logs; use CreateRepoSecret for credentials. +func (c *LiveClient) CreateProtectedCIVariable(ctx context.Context, owner, repo, name, value string) error { + path := fmt.Sprintf("/projects/%s/variables", projectPath(owner, repo)) + body := map[string]any{ + "key": name, + "value": value, + "protected": true, + "masked": false, + "variable_type": "env_var", + } + resp, err := c.post(ctx, path, body) + if err != nil { + return fmt.Errorf("create protected CI variable %s: %w", name, err) + } + resp.Body.Close() + return nil +} + +// --------------------------------------------------------------------------- +// Branch protection +// --------------------------------------------------------------------------- + +// IsProtectedBranch checks whether the given branch has protection rules. +// GitLab returns 200 if the branch is protected, 404 if not. +func (c *LiveClient) IsProtectedBranch(ctx context.Context, owner, repo, branch string) (bool, error) { + path := fmt.Sprintf("/projects/%s/protected_branches/%s", + projectPath(owner, repo), url.PathEscape(branch)) + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return false, fmt.Errorf("check branch protection: %w", err) + } + if resp.StatusCode == http.StatusOK { + resp.Body.Close() + return true, nil + } + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + return false, nil + } + return false, fmt.Errorf("check branch protection: %w", checkStatus(resp, http.StatusOK)) +} + +// --------------------------------------------------------------------------- +// Organization plan +// --------------------------------------------------------------------------- + +// GetOrgPlan returns the billing plan name for a GitLab namespace. +// Uses the Namespaces API where the plan field is documented, rather +// than the Groups API where it is undocumented and may be absent. +// Returns "free" if the plan field is empty. +func (c *LiveClient) GetOrgPlan(ctx context.Context, org string) (string, error) { + resp, err := c.get(ctx, fmt.Sprintf("/namespaces/%s", url.PathEscape(org))) + if err != nil { + return "", fmt.Errorf("get namespace plan: %w", err) + } + var ns struct { + Plan string `json:"plan"` + } + if err := decodeJSON(resp, &ns); err != nil { + return "", fmt.Errorf("decode namespace plan: %w", err) + } + if ns.Plan == "" { + return "free", nil + } + return ns.Plan, nil +} diff --git a/internal/forge/gitlab/gitlab.go b/internal/forge/gitlab/gitlab.go new file mode 100644 index 0000000000..5f2a1c02b9 --- /dev/null +++ b/internal/forge/gitlab/gitlab.go @@ -0,0 +1,356 @@ +// Package gitlab implements forge.Client for the GitLab REST API v4. +package gitlab + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/rand/v2" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// LiveClient implements forge.Client for the GitLab REST API v4. +type LiveClient struct { + http *http.Client + token string + baseURL string +} + +// Compile-time interface check. +var _ forge.Client = (*LiveClient)(nil) + +// Option configures the GitLab client. +type Option func(*LiveClient) + +// WithBaseURL sets a custom base URL for self-hosted GitLab instances. +// Non-https schemes are only allowed for loopback addresses (localhost, +// 127.0.0.1) to support test servers; other http:// URLs are rejected +// to prevent sending the PRIVATE-TOKEN header in cleartext. +func WithBaseURL(rawURL string) Option { + return func(c *LiveClient) { + c.baseURL = strings.TrimRight(rawURL, "/") + } +} + +// validateBaseURL checks that the base URL uses https, unless it points to a +// loopback address (for httptest servers). +func validateBaseURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + if u.Scheme == "https" { + return nil + } + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return nil + } + return fmt.Errorf("base URL %q uses insecure scheme %q; only https is allowed for non-loopback hosts", rawURL, u.Scheme) +} + +// New creates a new GitLab client with the given project access token. +// Returns an error if the configured base URL uses an insecure scheme +// for a non-loopback host. +func New(token string, opts ...Option) (*LiveClient, error) { + if token == "" { + return nil, fmt.Errorf("gitlab: token must not be empty") + } + c := &LiveClient{ + http: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if len(via) > 0 { + crossOrigin := req.URL.Host != via[0].URL.Host + tlsDowngrade := via[0].URL.Scheme == "https" && req.URL.Scheme != "https" + if crossOrigin || tlsDowngrade { + req.Header.Del("PRIVATE-TOKEN") + } + } + return nil + }, + }, + token: token, + baseURL: "https://gitlab.com", + } + for _, o := range opts { + o(c) + } + if err := validateBaseURL(c.baseURL); err != nil { + return nil, err + } + return c, nil +} + +// APIError represents an error response from the GitLab API. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("gitlab api: %d %s", e.StatusCode, e.Message) +} + +func (e *APIError) Unwrap() error { + if e.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + if e.StatusCode == http.StatusConflict { + return forge.ErrAlreadyExists + } + if e.StatusCode == http.StatusForbidden { + return forge.ErrForbidden + } + return nil +} + +const maxRetries = 5 + +func (c *LiveClient) apiURL(path string) string { + return c.baseURL + "/api/v4" + path +} + +// projectPath URL-encodes a "owner/repo" or nested group path for the +// GitLab API, which expects namespace/project as a URL-encoded slug. +func projectPath(owner, repo string) string { + return url.PathEscape(owner + "/" + repo) +} + +func (c *LiveClient) do(ctx context.Context, method, path string, body any) (*http.Response, error) { + reqURL := c.apiURL(path) + + var bodyData []byte + if body != nil { + var err error + bodyData, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + } + + for attempt := range maxRetries { + var reqBody io.Reader + if bodyData != nil { + reqBody = bytes.NewReader(bodyData) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("PRIVATE-TOKEN", c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + if isTransientError(err) && isIdempotent(method) && attempt < maxRetries-1 { + delay := retryDelay(nil, attempt) + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + continue + } + return nil, fmt.Errorf("http %s %s: %w", method, path, err) + } + + if isRetryable(resp, method) { + resp.Body.Close() + delay := retryDelay(resp, attempt) + if attempt == maxRetries-1 { + return nil, &APIError{ + StatusCode: resp.StatusCode, + Message: fmt.Sprintf("retryable error after %d attempts on %s %s", maxRetries, method, path), + } + } + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + continue + } + + return resp, nil + } + + return nil, fmt.Errorf("exhausted retries for %s %s", method, path) +} + +func isRetryable(resp *http.Response, method string) bool { + if resp.StatusCode == http.StatusTooManyRequests { + return true + } + if resp.StatusCode >= 500 && resp.StatusCode <= 504 && isIdempotent(method) { + return true + } + return false +} + +func isTransientError(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + return false +} + +func isIdempotent(method string) bool { + return method == http.MethodGet || method == http.MethodHead || + method == http.MethodPut || method == http.MethodDelete +} + +func retryDelay(resp *http.Response, attempt int) time.Duration { + const maxRetryAfterSecs = 300 + if resp != nil { + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs > 0 { + if secs > maxRetryAfterSecs { + secs = maxRetryAfterSecs + } + return time.Duration(secs) * time.Second + } + } + } + base := time.Duration(math.Pow(2, float64(attempt))) * time.Second + half := base / 2 + return half + time.Duration(rand.Int64N(int64(half)+1)) +} + +func checkStatus(resp *http.Response, acceptable ...int) error { + for _, code := range acceptable { + if resp.StatusCode == code { + return nil + } + } + + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + + var errResp struct { + Message any `json:"message"` + Error string `json:"error"` + } + if json.Unmarshal(data, &errResp) == nil { + msg := extractMessage(errResp.Message, errResp.Error) + if msg != "" { + return &APIError{StatusCode: resp.StatusCode, Message: msg} + } + } + return &APIError{StatusCode: resp.StatusCode, Message: http.StatusText(resp.StatusCode)} +} + +// extractMessage handles GitLab's inconsistent error format — "message" +// can be a string, a map, or an array depending on the endpoint. +func extractMessage(message any, fallback string) string { + switch v := message.(type) { + case string: + if v != "" { + return v + } + case map[string]any: + parts := make([]string, 0, len(v)) + for k, val := range v { + parts = append(parts, fmt.Sprintf("%s: %v", k, val)) + } + if len(parts) > 0 { + return strings.Join(parts, "; ") + } + case []any: + parts := make([]string, 0, len(v)) + for _, val := range v { + parts = append(parts, fmt.Sprintf("%v", val)) + } + if len(parts) > 0 { + return strings.Join(parts, "; ") + } + } + return fallback +} + +// extractConflictMessage extracts a human-readable message from a 409 response +// body. GitLab returns JSON with a "message" field; if parsing fails, the raw +// body is returned as-is. +func extractConflictMessage(data []byte) string { + var errResp struct { + Message any `json:"message"` + Error string `json:"error"` + } + if json.Unmarshal(data, &errResp) == nil { + if msg := extractMessage(errResp.Message, errResp.Error); msg != "" { + return msg + } + } + return string(data) +} + +func (c *LiveClient) get(ctx context.Context, path string) (*http.Response, error) { + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + if err := checkStatus(resp, http.StatusOK); err != nil { + return nil, err + } + return resp, nil +} + +func (c *LiveClient) post(ctx context.Context, path string, body any) (*http.Response, error) { + resp, err := c.do(ctx, http.MethodPost, path, body) + if err != nil { + return nil, err + } + if err := checkStatus(resp, http.StatusOK, http.StatusCreated); err != nil { + return nil, err + } + return resp, nil +} + +func (c *LiveClient) put(ctx context.Context, path string, body any) (*http.Response, error) { + resp, err := c.do(ctx, http.MethodPut, path, body) + if err != nil { + return nil, err + } + if err := checkStatus(resp, http.StatusOK, http.StatusCreated, http.StatusNoContent); err != nil { + return nil, err + } + return resp, nil +} + +func (c *LiveClient) delete_(ctx context.Context, path string) error { + resp, err := c.do(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + return checkStatus(resp, http.StatusOK, http.StatusAccepted, http.StatusNoContent) +} + +const maxResponseBody = 10 << 20 // 10 MB + +func decodeJSON(resp *http.Response, v any) error { + defer resp.Body.Close() + return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(v) +} diff --git a/internal/forge/gitlab/gitlab_test.go b/internal/forge/gitlab/gitlab_test.go new file mode 100644 index 0000000000..04c6d495e8 --- /dev/null +++ b/internal/forge/gitlab/gitlab_test.go @@ -0,0 +1,1402 @@ +package gitlab + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// setupTest creates a test server and a LiveClient pointed at it. +func setupTest(t *testing.T) (*LiveClient, *http.ServeMux) { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + client, err := New("test-token", WithBaseURL(srv.URL)) + require.NoError(t, err) + return client, mux +} + +// ---------- gitlab.go tests ---------- + +func TestNew(t *testing.T) { + c, err := New("my-token") + require.NoError(t, err) + assert.Equal(t, "my-token", c.token) + assert.Equal(t, "https://gitlab.com", c.baseURL) + assert.NotNil(t, c.http) +} + +func TestWithBaseURL(t *testing.T) { + c, err := New("tok", WithBaseURL("https://gitlab.example.com/")) + require.NoError(t, err) + assert.Equal(t, "https://gitlab.example.com", c.baseURL, "trailing slash should be trimmed") +} + +func TestNew_RejectsEmptyToken(t *testing.T) { + _, err := New("") + require.Error(t, err) + assert.Contains(t, err.Error(), "token must not be empty") +} + +func TestNew_RejectsInsecureURL(t *testing.T) { + _, err := New("tok", WithBaseURL("http://gitlab.example.com")) + require.Error(t, err) + assert.Contains(t, err.Error(), "insecure scheme") +} + +func TestNew_AllowsLocalhostHTTP(t *testing.T) { + c, err := New("tok", WithBaseURL("http://localhost:8080")) + require.NoError(t, err) + assert.Equal(t, "http://localhost:8080", c.baseURL) +} + +func TestAPIError_Error(t *testing.T) { + e := &APIError{StatusCode: 422, Message: "validation failed"} + assert.Equal(t, "gitlab api: 422 validation failed", e.Error()) +} + +func TestAPIError_Unwrap(t *testing.T) { + tests := []struct { + code int + target error + }{ + {http.StatusNotFound, forge.ErrNotFound}, + {http.StatusConflict, forge.ErrAlreadyExists}, + {http.StatusForbidden, forge.ErrForbidden}, + {http.StatusBadRequest, nil}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("status_%d", tt.code), func(t *testing.T) { + e := &APIError{StatusCode: tt.code, Message: "msg"} + assert.Equal(t, tt.target, e.Unwrap()) + }) + } +} + +func TestCheckStatus(t *testing.T) { + t.Run("acceptable status returns nil", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/ok", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + resp, err := client.do(context.Background(), http.MethodGet, "/ok", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.NoError(t, checkStatus(resp, http.StatusOK, http.StatusCreated)) + }) + + t.Run("unacceptable status returns APIError", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/bad", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprint(w, `{"message":"name already taken"}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + resp, err := client.do(context.Background(), http.MethodGet, "/bad", nil) + require.NoError(t, err) + err = checkStatus(resp, http.StatusOK) + require.Error(t, err) + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode) + assert.Equal(t, "name already taken", apiErr.Message) + }) + + t.Run("no JSON body falls back to status text", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/nojson", func(w http.ResponseWriter, r *http.Request) { + // Use 418 (not retryable, not in 500-504 range) + w.WriteHeader(http.StatusTeapot) + fmt.Fprint(w, "not json at all") + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + resp, err := client.do(context.Background(), http.MethodGet, "/nojson", nil) + require.NoError(t, err) + err = checkStatus(resp, http.StatusOK) + require.Error(t, err) + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "I'm a teapot", apiErr.Message) + }) +} + +func TestExtractMessage(t *testing.T) { + t.Run("string message", func(t *testing.T) { + assert.Equal(t, "something broke", extractMessage("something broke", "fallback")) + }) + t.Run("empty string uses fallback", func(t *testing.T) { + assert.Equal(t, "fallback", extractMessage("", "fallback")) + }) + t.Run("map message", func(t *testing.T) { + m := map[string]any{"name": []any{"is too short"}} + msg := extractMessage(m, "") + assert.Contains(t, msg, "name:") + }) + t.Run("array message", func(t *testing.T) { + a := []any{"error one", "error two"} + msg := extractMessage(a, "") + assert.Contains(t, msg, "error one") + assert.Contains(t, msg, "error two") + }) + t.Run("nil message uses fallback", func(t *testing.T) { + assert.Equal(t, "fallback", extractMessage(nil, "fallback")) + }) + t.Run("empty map uses fallback", func(t *testing.T) { + assert.Equal(t, "fallback", extractMessage(map[string]any{}, "fallback")) + }) + t.Run("empty array uses fallback", func(t *testing.T) { + assert.Equal(t, "fallback", extractMessage([]any{}, "fallback")) + }) +} + +func TestRetryOnServerError(t *testing.T) { + t.Run("retries on 500 then succeeds", func(t *testing.T) { + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/flaky", func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"status":"ok"}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + resp, err := client.do(context.Background(), http.MethodGet, "/flaky", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, 3, attempts.Load()) + }) + + t.Run("retries on 429 respects Retry-After", func(t *testing.T) { + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/ratelimit", func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + resp, err := client.do(context.Background(), http.MethodGet, "/ratelimit", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, 2, attempts.Load()) + }) + + t.Run("gives up after max retries", func(t *testing.T) { + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/down", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusInternalServerError) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + _, err = client.do(context.Background(), http.MethodGet, "/down", nil) + require.Error(t, err) + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.EqualValues(t, maxRetries, attempts.Load()) + }) +} + +func TestAuthHeader(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "test-token", r.Header.Get("PRIVATE-TOKEN")) + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + _, err := client.GetRepo(context.Background(), "owner", "repo") + require.NoError(t, err) +} + +// ---------- repo.go tests ---------- + +func TestGetRepo(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/mygroup%2Fmyrepo", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "name": "myrepo", + "path_with_namespace": "mygroup/myrepo", + "default_branch": "main", + "visibility": "public", + "archived": false, + "forked_from_project": nil, + }) + }) + + repo, err := client.GetRepo(context.Background(), "mygroup", "myrepo") + require.NoError(t, err) + assert.Equal(t, int64(42), repo.ID) + assert.Equal(t, "myrepo", repo.Name) + assert.Equal(t, "mygroup/myrepo", repo.FullName) + assert.Equal(t, "main", repo.DefaultBranch) + assert.False(t, repo.Private) + assert.False(t, repo.Archived) + assert.False(t, repo.Fork) +} + +func TestGetRepo_Fork(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/user%2Ffork", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 99, + "name": "fork", + "path_with_namespace": "user/fork", + "default_branch": "main", + "visibility": "private", + "archived": false, + "forked_from_project": map[string]any{"id": 1}, + }) + }) + + repo, err := client.GetRepo(context.Background(), "user", "fork") + require.NoError(t, err) + assert.True(t, repo.Private) + assert.True(t, repo.Fork) +} + +func TestGetRepo_NotFound(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Fgone", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"404 Project Not Found"}`) + }) + + _, err := client.GetRepo(context.Background(), "owner", "gone") + require.Error(t, err) + assert.True(t, forge.IsNotFound(err)) +} + +func TestListOrgRepos(t *testing.T) { + client, mux := setupTest(t) + + callCount := 0 + mux.HandleFunc("/api/v4/groups/myorg/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + callCount++ + + page := r.URL.Query().Get("page") + switch page { + case "1", "": + w.Header().Set("X-Next-Page", "2") + json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 1, "name": "public-repo", "path_with_namespace": "myorg/public-repo", + "default_branch": "main", "visibility": "public", "archived": false, + "forked_from_project": nil, + }, + { + "id": 2, "name": "archived-repo", "path_with_namespace": "myorg/archived-repo", + "default_branch": "main", "visibility": "public", "archived": true, + "forked_from_project": nil, + }, + { + "id": 3, "name": "forked-repo", "path_with_namespace": "myorg/forked-repo", + "default_branch": "main", "visibility": "public", "archived": false, + "forked_from_project": map[string]any{"id": 99}, + }, + { + "id": 4, "name": "private-repo", "path_with_namespace": "myorg/private-repo", + "default_branch": "main", "visibility": "private", "archived": false, + "forked_from_project": nil, + }, + }) + case "2": + // second page -- empty, stops pagination + json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + + repos, err := client.ListOrgRepos(context.Background(), "myorg") + require.NoError(t, err) + // Only public-repo passes the filter (not archived, not forked, not private) + require.Len(t, repos, 1) + assert.Equal(t, "public-repo", repos[0].Name) + assert.Equal(t, "myorg/public-repo", repos[0].FullName) + assert.Equal(t, int64(1), repos[0].ID) + assert.Equal(t, 1, callCount, "only one page fetched because first page had fewer than 100 items") +} + +func TestCreateRepo(t *testing.T) { + client, mux := setupTest(t) + + // Handler for GET /groups/:group (lookup group ID) + mux.HandleFunc("/api/v4/groups/myorg", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + json.NewEncoder(w).Encode(map[string]any{"id": 10}) + }) + + // Handler for POST /projects + mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "new-repo", body["name"]) + assert.Equal(t, float64(10), body["namespace_id"]) + assert.Equal(t, "A new repo", body["description"]) + assert.Equal(t, "private", body["visibility"]) + assert.Equal(t, true, body["initialize_with_readme"]) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": 55, + "name": "new-repo", + "path_with_namespace": "myorg/new-repo", + "default_branch": "main", + "visibility": "private", + }) + }) + + repo, err := client.CreateRepo(context.Background(), "myorg", "new-repo", "A new repo", true) + require.NoError(t, err) + assert.Equal(t, int64(55), repo.ID) + assert.Equal(t, "new-repo", repo.Name) + assert.Equal(t, "myorg/new-repo", repo.FullName) + assert.True(t, repo.Private) +} + +func TestCreateRepo_Public(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/groups/org", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": 5}) + }) + mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "public", body["visibility"]) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "org/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + repo, err := client.CreateRepo(context.Background(), "org", "repo", "desc", false) + require.NoError(t, err) + assert.False(t, repo.Private) +} + +func TestDeleteRepo(t *testing.T) { + client, mux := setupTest(t) + called := false + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + called = true + w.WriteHeader(http.StatusAccepted) + }) + + err := client.DeleteRepo(context.Background(), "owner", "repo") + require.NoError(t, err) + assert.True(t, called) +} + +func TestFindExistingFork(t *testing.T) { + t.Run("fork found", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/upstream%2Frepo/forks", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "true", r.URL.Query().Get("owned")) + assert.Equal(t, "1", r.URL.Query().Get("per_page")) + json.NewEncoder(w).Encode([]map[string]any{ + { + "path": "repo", + "namespace": map[string]any{ + "full_path": "myuser", + }, + }, + }) + }) + + forkOwner, forkRepo, err := client.FindExistingFork(context.Background(), "upstream", "repo") + require.NoError(t, err) + assert.Equal(t, "myuser", forkOwner) + assert.Equal(t, "repo", forkRepo) + }) + + t.Run("no fork found", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/upstream%2Frepo/forks", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + forkOwner, forkRepo, err := client.FindExistingFork(context.Background(), "upstream", "repo") + require.NoError(t, err) + assert.Empty(t, forkOwner) + assert.Empty(t, forkRepo) + }) +} + +func TestCreateFork(t *testing.T) { + t.Run("creates fork successfully", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/upstream%2Frepo/fork", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "path": "repo", + "namespace": map[string]any{ + "full_path": "contributor", + }, + }) + }) + + forkOwner, forkRepo, err := client.CreateFork(context.Background(), "upstream", "repo") + require.NoError(t, err) + assert.Equal(t, "contributor", forkOwner) + assert.Equal(t, "repo", forkRepo) + }) + + t.Run("conflict falls back to FindExistingFork", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/upstream%2Frepo/fork", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"already forked"}`) + }) + mux.HandleFunc("/api/v4/projects/upstream%2Frepo/forks", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + { + "path": "repo", + "namespace": map[string]any{ + "full_path": "existinguser", + }, + }, + }) + }) + + forkOwner, forkRepo, err := client.CreateFork(context.Background(), "upstream", "repo") + require.NoError(t, err) + assert.Equal(t, "existinguser", forkOwner) + assert.Equal(t, "repo", forkRepo) + }) +} + +func TestGetBranchRef(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/branches/main", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + json.NewEncoder(w).Encode(map[string]any{ + "commit": map[string]any{ + "id": "abc123def456", + }, + }) + }) + + sha, err := client.GetBranchRef(context.Background(), "owner", "repo", "main") + require.NoError(t, err) + assert.Equal(t, "abc123def456", sha) +} + +func TestCreateBranch(t *testing.T) { + client, mux := setupTest(t) + + // GetRepo call to get default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + } + }) + + // POST to create branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/branches", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "feature-branch", body["branch"]) + assert.Equal(t, "main", body["ref"]) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"name": "feature-branch"}) + }) + + err := client.CreateBranch(context.Background(), "owner", "repo", "feature-branch") + require.NoError(t, err) +} + +func TestGetRef(t *testing.T) { + t.Run("heads prefix", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits/main", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "sha-branch-123"}) + }) + + sha, err := client.GetRef(context.Background(), "owner", "repo", "heads/main") + require.NoError(t, err) + assert.Equal(t, "sha-branch-123", sha) + }) + + t.Run("tags prefix", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits/v1.0", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "sha-tag-456"}) + }) + + sha, err := client.GetRef(context.Background(), "owner", "repo", "tags/v1.0") + require.NoError(t, err) + assert.Equal(t, "sha-tag-456", sha) + }) + + t.Run("plain ref", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits/abc123", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"id": "abc123"}) + }) + + sha, err := client.GetRef(context.Background(), "owner", "repo", "abc123") + require.NoError(t, err) + assert.Equal(t, "abc123", sha) + }) +} + +func TestCreateFile(t *testing.T) { + client, mux := setupTest(t) + + // GetRepo for default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + // POST file create + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/README.md", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "main", body["branch"]) + assert.Equal(t, "add readme", body["commit_message"]) + assert.Equal(t, "base64", body["encoding"]) + + decoded, err := base64.StdEncoding.DecodeString(body["content"].(string)) + require.NoError(t, err) + assert.Equal(t, "hello world", string(decoded)) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"file_path": "README.md"}) + }) + + err := client.CreateFile(context.Background(), "owner", "repo", "README.md", "add readme", []byte("hello world")) + require.NoError(t, err) +} + +func TestGetFileContent(t *testing.T) { + client, mux := setupTest(t) + content := "file content here" + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + + // url.PathEscape encodes "docs/guide.md" to "docs%2Fguide.md" + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/docs%2Fguide.md", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "HEAD", r.URL.Query().Get("ref")) + json.NewEncoder(w).Encode(map[string]any{ + "content": encoded, + "encoding": "base64", + }) + }) + + data, err := client.GetFileContent(context.Background(), "owner", "repo", "docs/guide.md") + require.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestGetFileContentAtRef(t *testing.T) { + client, mux := setupTest(t) + content := "versioned content" + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/config.yml", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "v2.0", r.URL.Query().Get("ref")) + json.NewEncoder(w).Encode(map[string]any{ + "content": encoded, + "encoding": "base64", + }) + }) + + data, err := client.GetFileContentAtRef(context.Background(), "owner", "repo", "config.yml", "v2.0") + require.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestGetFileContent_PlainEncoding(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/plain.txt", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "content": "plain text content", + "encoding": "text", + }) + }) + + data, err := client.GetFileContent(context.Background(), "owner", "repo", "plain.txt") + require.NoError(t, err) + assert.Equal(t, "plain text content", string(data)) +} + +func TestCreateOrUpdateFile(t *testing.T) { + t.Run("create succeeds on first try", func(t *testing.T) { + client, mux := setupTest(t) + + // GetRepo for default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/new.txt", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"file_path": "new.txt"}) + }) + + err := client.CreateOrUpdateFile(context.Background(), "owner", "repo", "new.txt", "add file", []byte("data")) + require.NoError(t, err) + }) + + t.Run("falls back to PUT on already exists", func(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + callCount := 0 + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/existing.txt", func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch r.Method { + case http.MethodPost: + // First call: file already exists + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "message": "A file with this name already exists", + }) + case http.MethodPut: + // Second call: update succeeds + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"file_path": "existing.txt"}) + } + }) + + err := client.CreateOrUpdateFile(context.Background(), "owner", "repo", "existing.txt", "update file", []byte("new data")) + require.NoError(t, err) + assert.Equal(t, 2, callCount) // POST then PUT + }) +} + +func TestDeleteFile(t *testing.T) { + client, mux := setupTest(t) + + // GetRepo for default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/files/old.txt", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + + // Read and check the body payload + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &body)) + assert.Equal(t, "main", body["branch"]) + assert.Equal(t, "remove old file", body["commit_message"]) + + w.WriteHeader(http.StatusNoContent) + }) + + err := client.DeleteFile(context.Background(), "owner", "repo", "old.txt", "remove old file") + require.NoError(t, err) +} + +func TestDeleteFiles(t *testing.T) { + t.Run("deletes existing and skips missing atomically", func(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"id": "abc123", "path": "exists.txt", "type": "blob", "mode": "100644"}, + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &body)) + assert.Equal(t, "main", body["branch"]) + assert.Equal(t, "cleanup", body["commit_message"]) + actions := body["actions"].([]any) + assert.Len(t, actions, 1) + action := actions[0].(map[string]any) + assert.Equal(t, "delete", action["action"]) + assert.Equal(t, "exists.txt", action["file_path"]) + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"abc"}`) + }) + + deleted, err := client.DeleteFiles(context.Background(), "owner", "repo", "cleanup", []string{"exists.txt", "gone.txt"}) + require.NoError(t, err) + assert.Equal(t, 1, deleted) + }) + + t.Run("empty paths returns zero", func(t *testing.T) { + client, _ := setupTest(t) + deleted, err := client.DeleteFiles(context.Background(), "owner", "repo", "msg", nil) + require.NoError(t, err) + assert.Equal(t, 0, deleted) + }) + + t.Run("all paths missing returns zero", func(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + deleted, err := client.DeleteFiles(context.Background(), "owner", "repo", "cleanup", []string{"gone.txt"}) + require.NoError(t, err) + assert.Equal(t, 0, deleted) + }) +} + +func TestListDirectoryContents(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "docs", r.URL.Query().Get("path")) + assert.Equal(t, "main", r.URL.Query().Get("ref")) + + json.NewEncoder(w).Encode([]map[string]any{ + {"name": "README.md", "path": "docs/README.md", "type": "blob"}, + {"name": "api", "path": "docs/api", "type": "tree"}, + {"name": "guide.md", "path": "docs/guide.md", "type": "blob"}, + }) + }) + + entries, err := client.ListDirectoryContents(context.Background(), "owner", "repo", "docs", "main", false) + require.NoError(t, err) + require.Len(t, entries, 3) + + assert.Equal(t, "README.md", entries[0].Path) + assert.Equal(t, "file", entries[0].Type) + assert.Equal(t, "api", entries[1].Path) + assert.Equal(t, "dir", entries[1].Type) + assert.Equal(t, "guide.md", entries[2].Path) + assert.Equal(t, "file", entries[2].Type) +} + +func TestListDirectoryContents_Recursive(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "true", r.URL.Query().Get("recursive")) + json.NewEncoder(w).Encode([]map[string]any{ + {"name": "file.txt", "path": "file.txt", "type": "blob"}, + }) + }) + + entries, err := client.ListDirectoryContents(context.Background(), "owner", "repo", "", "main", true) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "file.txt", entries[0].Path) +} + +func TestListRepositoryFiles(t *testing.T) { + client, mux := setupTest(t) + + callCount := 0 + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + callCount++ + page := r.URL.Query().Get("page") + + switch page { + case "1": + w.Header().Set("X-Next-Page", "2") + // Return exactly 100 items to trigger pagination + entries := make([]map[string]any, 100) + for i := range entries { + entries[i] = map[string]any{ + "path": fmt.Sprintf("file%d.go", i), + "type": "blob", + } + } + json.NewEncoder(w).Encode(entries) + case "2": + json.NewEncoder(w).Encode([]map[string]any{ + {"path": "dir", "type": "tree"}, + {"path": "extra.go", "type": "blob"}, + }) + } + }) + + files, err := client.ListRepositoryFiles(context.Background(), "owner", "repo") + require.NoError(t, err) + // 100 from page 1 + 1 blob from page 2 (tree entries excluded) + assert.Len(t, files, 101) + assert.Equal(t, "file0.go", files[0]) + assert.Equal(t, "extra.go", files[100]) + assert.Equal(t, 2, callCount) +} + +func TestCommitFiles(t *testing.T) { + client, mux := setupTest(t) + + // GetRepo for default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + // Tree listing for idempotency check -- return empty tree (new repo) + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + // POST commit + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + assert.Equal(t, "main", body["branch"]) + assert.Equal(t, "initial commit", body["commit_message"]) + + actions := body["actions"].([]any) + require.Len(t, actions, 2) + + action0 := actions[0].(map[string]any) + assert.Equal(t, "create", action0["action"]) + assert.Equal(t, "README.md", action0["file_path"]) + + action1 := actions[1].(map[string]any) + assert.Equal(t, "create", action1["action"]) + assert.Equal(t, "script.sh", action1["file_path"]) + assert.Equal(t, true, action1["execute_filemode"]) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": "commit-sha-123"}) + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "initial commit", []forge.TreeFile{ + {Path: "README.md", Content: []byte("# Hello"), Mode: "100644"}, + {Path: "script.sh", Content: []byte("#!/bin/bash"), Mode: "100755"}, + }) + require.NoError(t, err) + assert.True(t, committed) +} + +func TestCommitFiles_Idempotent(t *testing.T) { + client, mux := setupTest(t) + + fileContent := []byte("# Hello") + fileSHA := blobSHA(fileContent) + + // GetRepo for default branch + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + // Tree listing -- file already matches + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + { + "id": fileSHA, + "path": "README.md", + "type": "blob", + "mode": "100644", + }, + }) + }) + + // The commits endpoint should NOT be called + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + t.Fatal("commits endpoint should not be called when files are unchanged") + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "no-op commit", []forge.TreeFile{ + {Path: "README.md", Content: fileContent, Mode: "100644"}, + }) + require.NoError(t, err) + assert.False(t, committed, "should not commit when files already match") +} + +func TestCommitFiles_UpdateExisting(t *testing.T) { + client, mux := setupTest(t) + + oldSHA := blobSHA([]byte("old content")) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"id": oldSHA, "path": "README.md", "type": "blob", "mode": "100644"}, + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + actions := body["actions"].([]any) + require.Len(t, actions, 1) + action := actions[0].(map[string]any) + assert.Equal(t, "update", action["action"]) + assert.Equal(t, "README.md", action["file_path"]) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": "new-sha"}) + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "update readme", []forge.TreeFile{ + {Path: "README.md", Content: []byte("new content"), Mode: "100644"}, + }) + require.NoError(t, err) + assert.True(t, committed) +} + +func TestCommitFiles_Delete(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"id": "someid", "path": "obsolete.txt", "type": "blob", "mode": "100644"}, + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + actions := body["actions"].([]any) + require.Len(t, actions, 1) + action := actions[0].(map[string]any) + assert.Equal(t, "delete", action["action"]) + assert.Equal(t, "obsolete.txt", action["file_path"]) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": "del-sha"}) + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "remove old file", []forge.TreeFile{ + {Path: "obsolete.txt", Delete: true}, + }) + require.NoError(t, err) + assert.True(t, committed) +} + +func TestCommitFiles_DeleteNonExistent(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "no-op delete", []forge.TreeFile{ + {Path: "nonexistent.txt", Delete: true}, + }) + require.NoError(t, err) + assert.False(t, committed, "deleting non-existent file should be a no-op") +} + +func TestCommitFiles_EmptyFiles(t *testing.T) { + client, _ := setupTest(t) + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "msg", nil) + require.NoError(t, err) + assert.False(t, committed) +} + +func TestCommitFiles_ModeChange(t *testing.T) { + client, mux := setupTest(t) + + existingSHA := blobSHA([]byte("#!/bin/bash")) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + // Same content but mode changed from 100755 to 100644 + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"id": existingSHA, "path": "script.sh", "type": "blob", "mode": "100755"}, + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + actions := body["actions"].([]any) + require.Len(t, actions, 1) + action := actions[0].(map[string]any) + assert.Equal(t, "update", action["action"]) + // When going from 100755 to 100644, execute_filemode should be false + assert.Equal(t, false, action["execute_filemode"]) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": "mode-sha"}) + }) + + committed, err := client.CommitFiles(context.Background(), "owner", "repo", "remove exec bit", []forge.TreeFile{ + {Path: "script.sh", Content: []byte("#!/bin/bash"), Mode: "100644"}, + }) + require.NoError(t, err) + assert.True(t, committed, "mode change should trigger commit") +} + +func TestProjectPath(t *testing.T) { + assert.Equal(t, "owner%2Frepo", projectPath("owner", "repo")) + assert.Equal(t, "group%2Fsubgroup%2Frepo", projectPath("group/subgroup", "repo")) +} + +func TestBlobSHA(t *testing.T) { + // Verify blobSHA produces a 40-char hex SHA-1 and is deterministic + content := []byte("hello") + sha := blobSHA(content) + assert.Len(t, sha, 40, "SHA-1 hex should be 40 chars") + // Same input always produces the same hash + assert.Equal(t, sha, blobSHA(content)) + // Different input produces a different hash + assert.NotEqual(t, sha, blobSHA([]byte("world"))) +} + +func TestCommitFilesToBranch(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + treeCalled := false + mux.HandleFunc("/api/v4/projects/own%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + treeCalled = true + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + var commitPayload map[string]any + mux.HandleFunc("/api/v4/projects/own%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &commitPayload) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"abc123"}`) + }) + + committed, err := client.CommitFilesToBranch(ctx, "own", "repo", "feature", "add file", []forge.TreeFile{ + {Path: "new.txt", Content: []byte("content"), Mode: "100644"}, + }) + + require.NoError(t, err) + assert.True(t, committed) + assert.True(t, treeCalled) + assert.Equal(t, "feature", commitPayload["branch"]) +} + +func TestCommitFilesToBranch_Empty(t *testing.T) { + client, err := New("token") + require.NoError(t, err) + committed, err := client.CommitFilesToBranch(context.Background(), "o", "r", "b", "msg", nil) + require.NoError(t, err) + assert.False(t, committed) +} + +func TestUpdateIssueComment_FoundInClosedIssues(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/own%2Frepo/issues", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + if state == "opened" { + json.NewEncoder(w).Encode([]map[string]any{ + {"iid": 1}, + }) + return + } + // closed issues + json.NewEncoder(w).Encode([]map[string]any{ + {"iid": 2}, + }) + }) + + // Note 99 not found on open issue 1 + mux.HandleFunc("/api/v4/projects/own%2Frepo/issues/1/notes/99", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"404 Not Found"}`) + }) + + // Note 99 found on closed issue 2 + mux.HandleFunc("/api/v4/projects/own%2Frepo/issues/2/notes/99", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"id":99}`) + return + } + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"404 Not Found"}`) + }) + + err := client.UpdateIssueComment(ctx, "own", "repo", 99, "updated body") + require.NoError(t, err) +} + +func TestDeleteIssueComment_NotFoundAnywhere(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/own%2Frepo/issues", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{}) + }) + + err := client.DeleteIssueComment(ctx, "own", "repo", 999) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not find issue containing this note") +} + +func TestGetAuthenticatedUserIdentity_NoEmail(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "name": "Test User", + "username": "testuser", + "email": "", + }) + }) + + identity, err := client.GetAuthenticatedUserIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, "Test User", identity.Name) +} + +func TestCreateOrUpdateFileOnBranch(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/own%2Frepo/repository/files/path%2Fto%2Ffile.txt", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"file_path":"path/to/file.txt"}`) + return + } + }) + + err := client.CreateOrUpdateFileOnBranch(ctx, "own", "repo", "feature", "path/to/file.txt", "add file", []byte("data")) + require.NoError(t, err) +} + +func TestCreateOrUpdateFileOnBranch_Update(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/own%2Frepo/repository/files/file.txt", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"message":"A file with this name already exists"}`) + return + } + if r.Method == http.MethodPut { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"file_path":"file.txt"}`) + return + } + }) + + err := client.CreateOrUpdateFileOnBranch(ctx, "own", "repo", "main", "file.txt", "update", []byte("new")) + require.NoError(t, err) +} + +func TestCreateFileOnBranch(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var gotBranch string + mux.HandleFunc("/api/v4/projects/own%2Frepo/repository/files/readme.md", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + gotBranch = body["branch"] + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"file_path":"readme.md"}`) + }) + + err := client.CreateFileOnBranch(ctx, "own", "repo", "dev", "readme.md", "init", []byte("# readme")) + require.NoError(t, err) + assert.Equal(t, "dev", gotBranch) +} + +func TestCheckRedirect_StripsTokenOnCrossOrigin(t *testing.T) { + var gotToken string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("PRIVATE-TOKEN") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + })) + t.Cleanup(target.Close) + + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/own%2Frepo", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/landed", http.StatusTemporaryRedirect) + }) + + resp, err := client.do(ctx, http.MethodGet, "/projects/own%2Frepo", nil) + require.NoError(t, err) + resp.Body.Close() + assert.Empty(t, gotToken, "PRIVATE-TOKEN should be stripped on cross-origin redirect") +} + +func TestCheckRedirect_StripsTokenOnTLSDowngrade(t *testing.T) { + client, err := New("test-token") + require.NoError(t, err) + + originalReq := &http.Request{ + URL: &url.URL{Scheme: "https", Host: "gitlab.com", Path: "/original"}, + Header: http.Header{"PRIVATE-TOKEN": []string{"test-token"}}, + } + redirectReq := &http.Request{ + URL: &url.URL{Scheme: "http", Host: "gitlab.com", Path: "/redirect"}, + Header: http.Header{"PRIVATE-TOKEN": []string{"test-token"}}, + } + + err = client.http.CheckRedirect(redirectReq, []*http.Request{originalReq}) + require.NoError(t, err) + assert.Empty(t, redirectReq.Header.Get("PRIVATE-TOKEN"), "PRIVATE-TOKEN should be stripped on TLS downgrade") +} + +func TestRetryDelay_CapsLargeRetryAfter(t *testing.T) { + resp := &http.Response{ + Header: http.Header{"Retry-After": []string{"9999999999"}}, + } + delay := retryDelay(resp, 0) + assert.Equal(t, 300*time.Second, delay, "large Retry-After should be capped at 300s") +} + +func TestTransportRetry_SkipsNonIdempotent(t *testing.T) { + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/create", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + // Close connection without response to trigger net.Error + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("server doesn't support hijack") + } + conn, _, _ := hj.Hijack() + conn.Close() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New("tok", WithBaseURL(srv.URL)) + require.NoError(t, err) + _, err = client.do(context.Background(), http.MethodPost, "/create", map[string]string{"key": "val"}) + require.Error(t, err) + assert.EqualValues(t, 1, attempts.Load(), "POST should not retry on transport error") +} diff --git a/internal/forge/gitlab/issue.go b/internal/forge/gitlab/issue.go new file mode 100644 index 0000000000..f67b87a23f --- /dev/null +++ b/internal/forge/gitlab/issue.go @@ -0,0 +1,397 @@ +package gitlab + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// CreateIssue creates a new issue on a GitLab project. +// Labels are sent as a comma-separated string per the GitLab API convention. +func (c *LiveClient) CreateIssue(ctx context.Context, owner, repo, title, body string, labels ...string) (*forge.Issue, error) { + path := fmt.Sprintf("/projects/%s/issues", projectPath(owner, repo)) + + payload := map[string]any{ + "title": title, + "description": body, + } + if len(labels) > 0 { + payload["labels"] = strings.Join(labels, ",") + } + + resp, err := c.post(ctx, path, payload) + if err != nil { + return nil, fmt.Errorf("create issue: %w", err) + } + + var result struct { + IID int `json:"iid"` + Title string `json:"title"` + Desc string `json:"description"` + WebURL string `json:"web_url"` + Labels gitlabLabels `json:"labels"` + } + if err := decodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("decode issue: %w", err) + } + + return &forge.Issue{ + Number: result.IID, + Title: result.Title, + Body: result.Desc, + URL: result.WebURL, + Labels: result.Labels.strings(), + }, nil +} + +// GetIssue returns an issue by its project-scoped IID. +func (c *LiveClient) GetIssue(ctx context.Context, owner, repo string, number int) (*forge.Issue, error) { + path := fmt.Sprintf("/projects/%s/issues/%d", projectPath(owner, repo), number) + + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("get issue #%d: %w", number, err) + } + + var result struct { + IID int `json:"iid"` + Title string `json:"title"` + Desc string `json:"description"` + WebURL string `json:"web_url"` + Labels gitlabLabels `json:"labels"` + } + if err := decodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("decode issue #%d: %w", number, err) + } + + return &forge.Issue{ + Number: result.IID, + Title: result.Title, + Body: result.Desc, + URL: result.WebURL, + Labels: result.Labels.strings(), + }, nil +} + +// ListOpenIssues returns open issues for a project, optionally filtered by labels. +// Paginates automatically until all matching issues are retrieved. +func (c *LiveClient) ListOpenIssues(ctx context.Context, owner, repo string, labelFilter ...string) ([]forge.Issue, error) { + var result []forge.Issue + + proj := projectPath(owner, repo) + for page := 1; page <= 100; page++ { + params := url.Values{} + params.Set("state", "opened") + params.Set("per_page", "100") + params.Set("page", fmt.Sprintf("%d", page)) + if len(labelFilter) > 0 { + params.Set("labels", strings.Join(labelFilter, ",")) + } + path := fmt.Sprintf("/projects/%s/issues?%s", proj, params.Encode()) + + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list open issues page %d: %w", page, err) + } + + var raw []struct { + IID int `json:"iid"` + Title string `json:"title"` + Desc string `json:"description"` + WebURL string `json:"web_url"` + Labels gitlabLabels `json:"labels"` + } + if err := decodeJSON(resp, &raw); err != nil { + return nil, fmt.Errorf("decode open issues page %d: %w", page, err) + } + + for _, item := range raw { + result = append(result, forge.Issue{ + Number: item.IID, + Title: item.Title, + Body: item.Desc, + URL: item.WebURL, + Labels: item.Labels.strings(), + }) + } + + if len(raw) < 100 { + break + } + } + + return result, nil +} + +// CloseIssue closes an issue by setting its state_event to "close". +func (c *LiveClient) CloseIssue(ctx context.Context, owner, repo string, number int) error { + path := fmt.Sprintf("/projects/%s/issues/%d", projectPath(owner, repo), number) + resp, err := c.put(ctx, path, map[string]string{"state_event": "close"}) + if err != nil { + return fmt.Errorf("close issue #%d: %w", number, err) + } + resp.Body.Close() + return nil +} + +// AddIssueLabels atomically appends labels to an existing issue using +// GitLab's add_labels parameter, avoiding the read-modify-write race of +// replacing the full label set. +func (c *LiveClient) AddIssueLabels(ctx context.Context, owner, repo string, number int, newLabels ...string) error { + if len(newLabels) == 0 { + return nil + } + + path := fmt.Sprintf("/projects/%s/issues/%d", projectPath(owner, repo), number) + resp, err := c.put(ctx, path, map[string]string{ + "add_labels": strings.Join(newLabels, ","), + }) + if err != nil { + return fmt.Errorf("add labels to issue #%d: %w", number, err) + } + resp.Body.Close() + return nil +} + +// ListIssueComments returns all notes on an issue, sorted ascending. +// GitLab calls issue comments "notes". +func (c *LiveClient) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]forge.IssueComment, error) { + var result []forge.IssueComment + + proj := projectPath(owner, repo) + for page := 1; page <= 100; page++ { + path := fmt.Sprintf("/projects/%s/issues/%d/notes?sort=asc&per_page=100&page=%d", proj, number, page) + + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list issue comments page %d: %w", page, err) + } + + var raw []struct { + ID int `json:"id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + Author struct { + Username string `json:"username"` + } `json:"author"` + } + if err := decodeJSON(resp, &raw); err != nil { + return nil, fmt.Errorf("decode issue comments page %d: %w", page, err) + } + + for _, r := range raw { + htmlURL := fmt.Sprintf("%s/-/issues/%d#note_%d", + c.projectWebURL(owner, repo), number, r.ID) + result = append(result, forge.IssueComment{ + ID: r.ID, + HTMLURL: htmlURL, + Body: r.Body, + Author: r.Author.Username, + CreatedAt: r.CreatedAt, + }) + } + + if len(raw) < 100 { + break + } + } + + return result, nil +} + +// CreateIssueComment creates a new note on an issue. +func (c *LiveClient) CreateIssueComment(ctx context.Context, owner, repo string, number int, body string) (*forge.IssueComment, error) { + path := fmt.Sprintf("/projects/%s/issues/%d/notes", projectPath(owner, repo), number) + + resp, err := c.post(ctx, path, map[string]string{"body": body}) + if err != nil { + return nil, fmt.Errorf("create issue comment on #%d: %w", number, err) + } + + var result struct { + ID int `json:"id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + Author struct { + Username string `json:"username"` + } `json:"author"` + } + if err := decodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("decode issue comment: %w", err) + } + + htmlURL := fmt.Sprintf("%s/-/issues/%d#note_%d", + c.projectWebURL(owner, repo), number, result.ID) + + return &forge.IssueComment{ + ID: result.ID, + HTMLURL: htmlURL, + Body: result.Body, + Author: result.Author.Username, + CreatedAt: result.CreatedAt, + }, nil +} + +// UpdateIssueComment updates the body of an existing note on an issue. +// GitLab's note API requires the issue IID in the URL path, but the +// forge.Client interface only provides the note ID. Since GitLab has no +// endpoint to look up a note by ID alone, we scan recent issues (open +// then closed) to locate the note. In practice, this is always called +// shortly after ListIssueComments or CreateIssueComment on the same issue. +func (c *LiveClient) UpdateIssueComment(ctx context.Context, owner, repo string, commentID int, body string) error { + return c.updateOrDeleteNote(ctx, owner, repo, commentID, &body) +} + +// DeleteIssueComment deletes a note on an issue. +// See UpdateIssueComment for the note-lookup strategy. +func (c *LiveClient) DeleteIssueComment(ctx context.Context, owner, repo string, commentID int) error { + return c.updateOrDeleteNote(ctx, owner, repo, commentID, nil) +} + +// updateOrDeleteNote finds the issue containing the given note and either +// updates its body (when body is non-nil) or deletes it. It scans recent +// issues ordered by update time to locate the note efficiently. +// +// Known limitation: GitLab's Notes API requires the issue IID to address a +// note, but the forge.Client interface only passes a bare commentID. This +// method must scan up to 1500 issues (10 pages open + 5 pages closed) to +// locate the parent issue. On projects with more issues, the note may not +// be found even though it exists. +func (c *LiveClient) updateOrDeleteNote(ctx context.Context, owner, repo string, noteID int, body *string) error { + proj := projectPath(owner, repo) + + // Scan open issues first (most common case). + for page := 1; page <= 10; page++ { + path := fmt.Sprintf("/projects/%s/issues?state=opened&per_page=100&page=%d&order_by=updated_at&sort=desc", proj, page) + resp, err := c.get(ctx, path) + if err != nil { + return fmt.Errorf("list issues to find note %d: %w", noteID, err) + } + + var issues []struct { + IID int `json:"iid"` + } + if err := decodeJSON(resp, &issues); err != nil { + return fmt.Errorf("decode issues: %w", err) + } + + for _, issue := range issues { + err := c.tryNoteOperation(ctx, proj, issue.IID, noteID, body) + if err == nil { + return nil + } + if !forge.IsNotFound(err) { + return err + } + } + + if len(issues) < 100 { + break + } + } + + // Try closed issues (the issue may have been closed after the comment was created). + for page := 1; page <= 5; page++ { + path := fmt.Sprintf("/projects/%s/issues?state=closed&per_page=100&page=%d&order_by=updated_at&sort=desc", proj, page) + resp, err := c.get(ctx, path) + if err != nil { + return fmt.Errorf("list closed issues to find note %d: %w", noteID, err) + } + + var issues []struct { + IID int `json:"iid"` + } + if err := decodeJSON(resp, &issues); err != nil { + return fmt.Errorf("decode closed issues: %w", err) + } + + for _, issue := range issues { + err := c.tryNoteOperation(ctx, proj, issue.IID, noteID, body) + if err == nil { + return nil + } + if !forge.IsNotFound(err) { + return err + } + } + + if len(issues) < 100 { + break + } + } + + op := "update" + if body == nil { + op = "delete" + } + return fmt.Errorf("%s note %d: could not find issue containing this note", op, noteID) +} + +// tryNoteOperation attempts to update or delete a note on the given issue. +// Returns nil on success, or an error wrapping forge.ErrNotFound if the +// note doesn't exist on this issue. +func (c *LiveClient) tryNoteOperation(ctx context.Context, proj string, issueIID, noteID int, body *string) error { + notePath := fmt.Sprintf("/projects/%s/issues/%d/notes/%d", proj, issueIID, noteID) + + if body == nil { + return c.delete_(ctx, notePath) + } + + resp, err := c.put(ctx, notePath, map[string]string{"body": *body}) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// MinimizeComment is not supported on GitLab -- there is no equivalent +// concept of hiding/minimizing individual comments. +func (c *LiveClient) MinimizeComment(_ context.Context, _, _ string) error { + return forge.ErrNotSupported +} + +// projectWebURL returns the web URL for a project (without trailing slash). +func (c *LiveClient) projectWebURL(owner, repo string) string { + return c.baseURL + "/" + owner + "/" + repo +} + +// gitlabLabels handles GitLab's inconsistent label format -- labels may be +// returned as a JSON array of strings (modern API) or as an array of +// objects with a "title" field (older API or some endpoints). +type gitlabLabels []string + +func (l *gitlabLabels) UnmarshalJSON(data []byte) error { + // Try array of strings first (modern format). + var strLabels []string + if err := json.Unmarshal(data, &strLabels); err == nil { + *l = strLabels + return nil + } + + // Fall back to array of objects with "title" field. + var objLabels []struct { + Title string `json:"title"` + } + if err := json.Unmarshal(data, &objLabels); err == nil { + result := make([]string, 0, len(objLabels)) + for _, ol := range objLabels { + result = append(result, ol.Title) + } + *l = result + return nil + } + + return fmt.Errorf("labels: unexpected JSON format: %s", string(data)) +} + +func (l gitlabLabels) strings() []string { + if l == nil { + return nil + } + return []string(l) +} diff --git a/internal/forge/gitlab/methods_test.go b/internal/forge/gitlab/methods_test.go new file mode 100644 index 0000000000..c8701997dc --- /dev/null +++ b/internal/forge/gitlab/methods_test.go @@ -0,0 +1,1996 @@ +package gitlab + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readJSONBody unmarshals the JSON body from a request into v. +func readJSONBody(t *testing.T, r *http.Request, v any) { + t.Helper() + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, v)) +} + +// writeJSON writes v as JSON to the response with the given status code. +func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(v)) +} + +// --------------------------------------------------------------------------- +// issue.go tests +// --------------------------------------------------------------------------- + +func TestCreateIssue(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "test-token", r.Header.Get("PRIVATE-TOKEN")) + + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "Bug report", body["title"]) + assert.Equal(t, "Something broke", body["description"]) + assert.Equal(t, "bug,urgent", body["labels"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{ + "iid": 42, + "title": "Bug report", + "description": "Something broke", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/42", + "labels": []string{"bug", "urgent"}, + }) + }) + + issue, err := client.CreateIssue(ctx, "myorg", "myrepo", "Bug report", "Something broke", "bug", "urgent") + require.NoError(t, err) + assert.Equal(t, 42, issue.Number) + assert.Equal(t, "Bug report", issue.Title) + assert.Equal(t, "Something broke", issue.Body) + assert.Equal(t, "https://gitlab.com/myorg/myrepo/-/issues/42", issue.URL) + assert.Equal(t, []string{"bug", "urgent"}, issue.Labels) +} + +func TestCreateIssue_NoLabels(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + readJSONBody(t, r, &body) + _, hasLabels := body["labels"] + assert.False(t, hasLabels, "labels should not be sent when none provided") + + writeJSON(t, w, http.StatusCreated, map[string]any{ + "iid": 1, + "title": "No labels", + "description": "", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/1", + "labels": []string{}, + }) + }) + + issue, err := client.CreateIssue(ctx, "myorg", "myrepo", "No labels", "") + require.NoError(t, err) + assert.Equal(t, 1, issue.Number) +} + +func TestGetIssue_StringLabels(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/5", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]any{ + "iid": 5, + "title": "Test issue", + "description": "Issue body", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/5", + "labels": []string{"feature", "docs"}, + }) + }) + + issue, err := client.GetIssue(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) + assert.Equal(t, 5, issue.Number) + assert.Equal(t, "Test issue", issue.Title) + assert.Equal(t, "Issue body", issue.Body) + assert.Equal(t, []string{"feature", "docs"}, issue.Labels) +} + +func TestGetIssue_ObjectLabels(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/7", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "iid": 7, + "title": "Object labels", + "description": "", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/7", + "labels": []map[string]any{ + {"title": "alpha"}, + {"title": "beta"}, + }, + }) + }) + + issue, err := client.GetIssue(ctx, "myorg", "myrepo", 7) + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta"}, issue.Labels) +} + +func TestGetIssue_NotFound(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/999", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]string{"message": "404 Not Found"}) + }) + + _, err := client.GetIssue(ctx, "myorg", "myrepo", 999) + require.Error(t, err) + assert.True(t, forge.IsNotFound(err)) +} + +func TestListOpenIssues(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "opened", r.URL.Query().Get("state")) + assert.Equal(t, "100", r.URL.Query().Get("per_page")) + + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "iid": 1, + "title": "First", + "description": "First body", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/1", + "labels": []string{"bug"}, + }, + { + "iid": 2, + "title": "Second", + "description": "Second body", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/2", + "labels": []string{}, + }, + }) + }) + + issues, err := client.ListOpenIssues(ctx, "myorg", "myrepo") + require.NoError(t, err) + require.Len(t, issues, 2) + assert.Equal(t, "First", issues[0].Title) + assert.Equal(t, "Second", issues[1].Title) +} + +func TestListOpenIssues_WithLabelFilter(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "bug,critical", r.URL.Query().Get("labels")) + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "iid": 3, + "title": "Critical bug", + "description": "", + "web_url": "https://gitlab.com/myorg/myrepo/-/issues/3", + "labels": []string{"bug", "critical"}, + }, + }) + }) + + issues, err := client.ListOpenIssues(ctx, "myorg", "myrepo", "bug", "critical") + require.NoError(t, err) + require.Len(t, issues, 1) + assert.Equal(t, "Critical bug", issues[0].Title) +} + +func TestCloseIssue(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/10", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "close", body["state_event"]) + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 10, "state": "closed"}) + }) + + err := client.CloseIssue(ctx, "myorg", "myrepo", 10) + require.NoError(t, err) +} + +func TestAddIssueLabels(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/5", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "new-label", body["add_labels"]) + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 5}) + }) + + err := client.AddIssueLabels(ctx, "myorg", "myrepo", 5, "new-label") + require.NoError(t, err) +} + +func TestAddIssueLabels_EmptyNewLabels(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + // Should return immediately without making any API calls + err := client.AddIssueLabels(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) +} + +func TestAddIssueLabels_MultipleLabels(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/5", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "bug,new", body["add_labels"]) + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 5}) + }) + + err := client.AddIssueLabels(ctx, "myorg", "myrepo", 5, "bug", "new") + require.NoError(t, err) +} + +func TestListIssueComments(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/3/notes", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "asc", r.URL.Query().Get("sort")) + + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "id": 100, + "body": "First comment", + "created_at": "2024-01-01T00:00:00Z", + "author": map[string]string{"username": "alice"}, + }, + { + "id": 101, + "body": "Second comment", + "created_at": "2024-01-02T00:00:00Z", + "author": map[string]string{"username": "bob"}, + }, + }) + }) + + comments, err := client.ListIssueComments(ctx, "myorg", "myrepo", 3) + require.NoError(t, err) + require.Len(t, comments, 2) + + assert.Equal(t, 100, comments[0].ID) + assert.Equal(t, "First comment", comments[0].Body) + assert.Equal(t, "alice", comments[0].Author) + assert.Equal(t, "2024-01-01T00:00:00Z", comments[0].CreatedAt) + // Verify HTMLURL construction + assert.Contains(t, comments[0].HTMLURL, "/-/issues/3#note_100") + + assert.Equal(t, 101, comments[1].ID) + assert.Equal(t, "bob", comments[1].Author) +} + +func TestCreateIssueComment(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/3/notes", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "Hello world", body["body"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{ + "id": 200, + "body": "Hello world", + "created_at": "2024-03-01T12:00:00Z", + "author": map[string]string{"username": "botuser"}, + }) + }) + + comment, err := client.CreateIssueComment(ctx, "myorg", "myrepo", 3, "Hello world") + require.NoError(t, err) + assert.Equal(t, 200, comment.ID) + assert.Equal(t, "Hello world", comment.Body) + assert.Equal(t, "botuser", comment.Author) + assert.Contains(t, comment.HTMLURL, "/-/issues/3#note_200") +} + +func TestUpdateIssueComment(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + // Register handler for listing open issues (the note-scan approach) + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + {"iid": 1}, + {"iid": 2}, + }) + }) + + // Note 500 is on issue 2 -- issue 1 returns 404, issue 2 returns 200 + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/1/notes/500", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]string{"message": "404 Not Found"}) + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/2/notes/500", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "Updated body", body["body"]) + writeJSON(t, w, http.StatusOK, map[string]any{"id": 500, "body": "Updated body"}) + }) + + err := client.UpdateIssueComment(ctx, "myorg", "myrepo", 500, "Updated body") + require.NoError(t, err) +} + +func TestDeleteIssueComment(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + // Listing open issues returns one issue + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + {"iid": 10}, + }) + }) + + // The note exists on issue 10 + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/issues/10/notes/600", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + err := client.DeleteIssueComment(ctx, "myorg", "myrepo", 600) + require.NoError(t, err) +} + +func TestMinimizeComment(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + err := client.MinimizeComment(ctx, "myorg", "myrepo") + require.ErrorIs(t, err, forge.ErrNotSupported) +} + +// --------------------------------------------------------------------------- +// mr.go tests +// --------------------------------------------------------------------------- + +func TestCreateChangeProposal(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "feature-branch", body["source_branch"]) + assert.Equal(t, "main", body["target_branch"]) + assert.Equal(t, "New feature", body["title"]) + assert.Equal(t, "Feature description", body["description"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{ + "iid": 99, + "title": "New feature", + "web_url": "https://gitlab.com/myorg/myrepo/-/merge_requests/99", + "source_branch": "feature-branch", + "target_branch": "main", + }) + }) + + cp, err := client.CreateChangeProposal(ctx, "myorg", "myrepo", "New feature", "Feature description", "feature-branch", "main") + require.NoError(t, err) + assert.Equal(t, 99, cp.Number) + assert.Equal(t, "New feature", cp.Title) + assert.Equal(t, "https://gitlab.com/myorg/myrepo/-/merge_requests/99", cp.URL) + assert.Equal(t, "feature-branch", cp.Head) + assert.Equal(t, "main", cp.Base) +} + +func TestListRepoPullRequests(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "opened", r.URL.Query().Get("state")) + assert.Equal(t, "100", r.URL.Query().Get("per_page")) + + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "iid": 1, + "title": "MR One", + "web_url": "https://gitlab.com/myorg/myrepo/-/merge_requests/1", + "source_branch": "branch-1", + "target_branch": "main", + }, + { + "iid": 2, + "title": "MR Two", + "web_url": "https://gitlab.com/myorg/myrepo/-/merge_requests/2", + "source_branch": "branch-2", + "target_branch": "main", + }, + }) + }) + + mrs, err := client.ListRepoPullRequests(ctx, "myorg", "myrepo") + require.NoError(t, err) + require.Len(t, mrs, 2) + assert.Equal(t, "MR One", mrs[0].Title) + assert.Equal(t, "branch-1", mrs[0].Head) + assert.Equal(t, "MR Two", mrs[1].Title) +} + +func TestGetPullRequestHeadSHA(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]any{ + "sha": "abc123def456", + }) + }) + + sha, err := client.GetPullRequestHeadSHA(ctx, "myorg", "myrepo", 10) + require.NoError(t, err) + assert.Equal(t, "abc123def456", sha) +} + +func TestListPullRequestFiles(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/5/diffs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, []map[string]any{ + {"old_path": "old.go", "new_path": "new.go"}, + {"old_path": "same.go", "new_path": "same.go"}, + }) + }) + + files, err := client.ListPullRequestFiles(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) + require.Len(t, files, 2) + assert.Equal(t, "new.go", files[0]) + assert.Equal(t, "same.go", files[1]) +} + +func TestListPullRequestFileDiffs(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/5/diffs", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "new_path": "file.go", + "diff": "@@ -1,3 +1,4 @@\n+new line\n", + }, + { + "new_path": "other.go", + "diff": "@@ -10,2 +10,3 @@\n+another\n", + }, + }) + }) + + diffs, err := client.ListPullRequestFileDiffs(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) + require.Len(t, diffs, 2) + assert.Equal(t, "file.go", diffs[0].Path) + assert.Contains(t, diffs[0].Patch, "+new line") + assert.Equal(t, "other.go", diffs[1].Path) +} + +func TestMergeChangeProposal(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/15/merge", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 15, "state": "merged"}) + }) + + err := client.MergeChangeProposal(ctx, "myorg", "myrepo", 15) + require.NoError(t, err) +} + +func TestUpdatePullRequestBranch(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/20/rebase", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + writeJSON(t, w, http.StatusAccepted, map[string]any{"rebase_in_progress": true}) + }) + + err := client.UpdatePullRequestBranch(ctx, "myorg", "myrepo", 20) + require.NoError(t, err) +} + +func TestCreatePullRequestReview_Approve(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + approved := false + var approveBody map[string]string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/approve", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + readJSONBody(t, r, &approveBody) + approved = true + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 30}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "APPROVE", "", "sha123", nil) + require.NoError(t, err) + assert.True(t, approved, "approve endpoint should have been called") + assert.Equal(t, "sha123", approveBody["sha"], "commitSHA should be passed as sha parameter") +} + +func TestCreatePullRequestReview_ApproveWithBody(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var approvedCalled, noteCalled bool + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/approve", func(w http.ResponseWriter, r *http.Request) { + approvedCalled = true + writeJSON(t, w, http.StatusOK, map[string]any{"iid": 30}) + }) + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/notes", func(w http.ResponseWriter, r *http.Request) { + noteCalled = true + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "LGTM!", body["body"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"id": 1, "body": "LGTM!"}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "APPROVE", "LGTM!", "sha123", nil) + require.NoError(t, err) + assert.True(t, approvedCalled) + assert.True(t, noteCalled) +} + +func TestCreatePullRequestReview_Comment(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var notes []string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/notes", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + notes = append(notes, body["body"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"id": len(notes), "body": body["body"]}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "COMMENT", "Review body", "sha123", []forge.ReviewComment{ + {Path: "main.go", Line: 10, Body: "Fix this"}, + }) + require.NoError(t, err) + require.Len(t, notes, 2) + assert.Equal(t, "Review body", notes[0]) + assert.Contains(t, notes[1], "`main.go:10`") + assert.Contains(t, notes[1], "Fix this") +} + +func TestCreatePullRequestReview_CommentWithFileLevel(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var notes []string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/notes", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + readJSONBody(t, r, &body) + notes = append(notes, body["body"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"id": len(notes), "body": body["body"]}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "COMMENT", "", "sha123", []forge.ReviewComment{ + {Path: "readme.md", Line: 0, Body: "File-level comment"}, + }) + require.NoError(t, err) + // No main body, so only the inline comment + require.Len(t, notes, 1) + assert.Contains(t, notes[0], "`readme.md`") + assert.Contains(t, notes[0], "File-level comment") + // Should NOT contain a line number + assert.NotContains(t, notes[0], "readme.md:0") +} + +func TestCreatePullRequestReview_InvalidEvent(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "INVALID", "", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid event") +} + +func TestListPullRequestReviews(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "approved_by": []map[string]any{ + {"user": map[string]any{"id": 42, "username": "approver1"}}, + {"user": map[string]any{"id": 99, "username": "approver2"}}, + }, + }) + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/notes", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "id": 300, + "body": "Looks good", + "system": false, + "created_at": "2024-02-01T00:00:00Z", + "author": map[string]any{"id": 50, "username": "commenter"}, + }, + { + "id": 301, + "body": "assigned to @someone", + "system": true, + "created_at": "2024-02-02T00:00:00Z", + "author": map[string]any{"id": 0, "username": "system"}, + }, + }) + }) + + reviews, err := client.ListPullRequestReviews(ctx, "myorg", "myrepo", 10) + require.NoError(t, err) + + // 2 approvers + 1 non-system note = 3 reviews + require.Len(t, reviews, 3) + + // Approvals come first (IDs are negated user IDs to avoid collision with note IDs) + assert.Equal(t, "APPROVED", reviews[0].State) + assert.Equal(t, "approver1", reviews[0].User) + assert.Equal(t, -42, reviews[0].ID) + assert.Equal(t, "APPROVED", reviews[1].State) + assert.Equal(t, "approver2", reviews[1].User) + assert.Equal(t, -99, reviews[1].ID) + + // Then comments (system notes are skipped) + assert.Equal(t, "COMMENTED", reviews[2].State) + assert.Equal(t, "commenter", reviews[2].User) + assert.Equal(t, "Looks good", reviews[2].Body) +} + +func TestListPullRequestReviews_ChangesRequested(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{"approved_by": []map[string]any{}}) + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/notes", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "id": 400, + "body": requestChangesMarker + "\n\nPlease fix the error handling", + "system": false, + "created_at": "2024-03-01T00:00:00Z", + "author": map[string]any{"id": 50, "username": "reviewer"}, + }, + { + "id": 401, + "body": "Regular comment", + "system": false, + "created_at": "2024-03-02T00:00:00Z", + "author": map[string]any{"id": 51, "username": "commenter"}, + }, + }) + }) + + reviews, err := client.ListPullRequestReviews(ctx, "myorg", "myrepo", 10) + require.NoError(t, err) + require.Len(t, reviews, 2) + assert.Equal(t, "CHANGES_REQUESTED", reviews[0].State) + assert.Equal(t, "COMMENTED", reviews[1].State) +} + +func TestDismissPullRequestReview(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "approved_by": []map[string]any{ + {"user": map[string]any{"id": 42, "username": "botuser"}}, + }, + }) + }) + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{"username": "botuser"}) + }) + + unapproved := false + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/unapprove", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + unapproved = true + writeJSON(t, w, http.StatusCreated, map[string]any{}) + }) + + notePosted := false + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/notes", func(w http.ResponseWriter, r *http.Request) { + notePosted = true + var body map[string]string + readJSONBody(t, r, &body) + assert.Contains(t, body["body"], "Review dismissed:") + assert.Contains(t, body["body"], "outdated") + writeJSON(t, w, http.StatusCreated, map[string]any{"id": 1}) + }) + + err := client.DismissPullRequestReview(ctx, "myorg", "myrepo", 10, -42, "outdated") + require.NoError(t, err) + assert.True(t, unapproved) + assert.True(t, notePosted) +} + +func TestDismissPullRequestReview_NonApproval_NoMarker(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "approved_by": []map[string]any{ + {"user": map[string]any{"id": 99, "username": "someone"}}, + }, + }) + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/notes/42", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(t, w, http.StatusOK, map[string]string{"body": "just a comment"}) + return + } + t.Fatal("should not update a note without the marker") + }) + + err := client.DismissPullRequestReview(ctx, "myorg", "myrepo", 10, 42, "") + require.NoError(t, err) +} + +func TestDismissPullRequestReview_RequestChangesNote(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{"approved_by": []map[string]any{}}) + }) + + var updatedBody string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/notes/42", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(t, w, http.StatusOK, map[string]string{ + "body": requestChangesMarker + "\n\nPlease fix this", + }) + return + } + var body map[string]string + readJSONBody(t, r, &body) + updatedBody = body["body"] + writeJSON(t, w, http.StatusOK, map[string]any{"id": 42}) + }) + + err := client.DismissPullRequestReview(ctx, "myorg", "myrepo", 10, 42, "superseded") + require.NoError(t, err) + assert.Contains(t, updatedBody, dismissedMarker) + assert.NotContains(t, updatedBody, requestChangesMarker) + assert.Contains(t, updatedBody, "superseded") +} + +// --------------------------------------------------------------------------- +// ci.go tests +// --------------------------------------------------------------------------- + +func TestGetAuthenticatedUser(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "test-token", r.Header.Get("PRIVATE-TOKEN")) + writeJSON(t, w, http.StatusOK, map[string]string{"username": "testbot"}) + }) + + username, err := client.GetAuthenticatedUser(ctx) + require.NoError(t, err) + assert.Equal(t, "testbot", username) +} + +func TestGetAuthenticatedUserIdentity(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]string{ + "name": "Test Bot", + "email": "bot@example.com", + }) + }) + + identity, err := client.GetAuthenticatedUserIdentity(ctx) + require.NoError(t, err) + assert.Equal(t, "Test Bot", identity.Name) + assert.Equal(t, "bot@example.com", identity.Email) +} + +func TestGetTokenScopes(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + scopes, err := client.GetTokenScopes(ctx) + require.NoError(t, err) + assert.Nil(t, scopes) +} + +func TestIsInstallationToken(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + isInstall, err := client.IsInstallationToken(ctx) + require.NoError(t, err) + assert.False(t, isInstall) +} + +func TestCreateRepoSecret(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "MY_SECRET", body["key"]) + assert.Equal(t, "s3cr3t", body["value"]) + assert.Equal(t, true, body["protected"]) + assert.Equal(t, true, body["masked"]) + assert.Equal(t, "env_var", body["variable_type"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{"key": "MY_SECRET"}) + }) + + err := client.CreateRepoSecret(ctx, "myorg", "myrepo", "MY_SECRET", "s3cr3t") + require.NoError(t, err) +} + +func TestRepoSecretExists_True(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_SECRET", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]string{"key": "MY_SECRET"}) + }) + + exists, err := client.RepoSecretExists(ctx, "myorg", "myrepo", "MY_SECRET") + require.NoError(t, err) + assert.True(t, exists) +} + +func TestRepoSecretExists_False(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MISSING", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + exists, err := client.RepoSecretExists(ctx, "myorg", "myrepo", "MISSING") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestRepoSecretExists_UnexpectedStatus(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/BAD", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + _, err := client.RepoSecretExists(ctx, "myorg", "myrepo", "BAD") + require.Error(t, err) +} + +func TestDeleteRepoSecret(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_SECRET", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + err := client.DeleteRepoSecret(ctx, "myorg", "myrepo", "MY_SECRET") + require.NoError(t, err) +} + +func TestDeleteRepoSecret_AlreadyGone(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/GONE", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + // Should be idempotent: 404 is not an error + err := client.DeleteRepoSecret(ctx, "myorg", "myrepo", "GONE") + require.NoError(t, err) +} + +func TestCreateOrUpdateRepoVariable_Create(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "MY_VAR", body["key"]) + assert.Equal(t, "my-value", body["value"]) + assert.Equal(t, "env_var", body["variable_type"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{"key": "MY_VAR"}) + }) + + err := client.CreateOrUpdateRepoVariable(ctx, "myorg", "myrepo", "MY_VAR", "my-value") + require.NoError(t, err) +} + +func TestCreateOrUpdateRepoVariable_UpdateOnConflict(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + // POST returns 409 Conflict (variable already exists) + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + writeJSON(t, w, http.StatusConflict, map[string]string{"message": "MY_VAR has already been taken"}) + return + } + }) + + // Then it falls back to PUT + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_VAR", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "new-value", body["value"]) + writeJSON(t, w, http.StatusOK, map[string]any{"key": "MY_VAR", "value": "new-value"}) + }) + + err := client.CreateOrUpdateRepoVariable(ctx, "myorg", "myrepo", "MY_VAR", "new-value") + require.NoError(t, err) +} + +func TestGetRepoVariable_Found(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_VAR", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]string{"key": "MY_VAR", "value": "hello"}) + }) + + value, found, err := client.GetRepoVariable(ctx, "myorg", "myrepo", "MY_VAR") + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "hello", value) +} + +func TestGetRepoVariable_NotFound(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MISSING", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + value, found, err := client.GetRepoVariable(ctx, "myorg", "myrepo", "MISSING") + require.NoError(t, err) + assert.False(t, found) + assert.Equal(t, "", value) +} + +func TestListRepoVariables(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, []map[string]string{ + {"key": "VAR1", "value": "val1"}, + {"key": "VAR2", "value": "val2"}, + }) + }) + + vars, err := client.ListRepoVariables(ctx, "myorg", "myrepo") + require.NoError(t, err) + assert.Equal(t, map[string]string{"VAR1": "val1", "VAR2": "val2"}, vars) +} + +func TestListRepoVariables_Pagination(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + pageCount := 0 + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + pageCount++ + page := r.URL.Query().Get("page") + + if page == "1" { + // Return exactly 100 items to trigger next page + vars := make([]map[string]string, 100) + for i := range vars { + vars[i] = map[string]string{ + "key": "VAR_" + page + "_" + string(rune('A'+i%26)), + "value": "val", + } + } + writeJSON(t, w, http.StatusOK, vars) + return + } + // Page 2 returns fewer than 100, ending pagination + writeJSON(t, w, http.StatusOK, []map[string]string{ + {"key": "LAST_VAR", "value": "last"}, + }) + }) + + vars, err := client.ListRepoVariables(ctx, "myorg", "myrepo") + require.NoError(t, err) + assert.Equal(t, 2, pageCount) + assert.Contains(t, vars, "LAST_VAR") +} + +func TestCreatePipelineSchedule(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var body map[string]string + readJSONBody(t, r, &body) + assert.Equal(t, "main", body["ref"]) + assert.Equal(t, "Nightly build", body["description"]) + assert.Equal(t, "0 2 * * *", body["cron"]) + assert.Equal(t, "UTC", body["cron_timezone"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{"id": 123}) + return + } + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules/123/variables", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]string + readJSONBody(t, r, &body) + assert.NotEmpty(t, body["key"]) + assert.NotEmpty(t, body["value"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"key": body["key"]}) + }) + + id, err := client.CreatePipelineSchedule(ctx, "myorg", "myrepo", "main", "Nightly build", "0 2 * * *", map[string]string{ + "ENV": "production", + }) + require.NoError(t, err) + assert.Equal(t, int64(123), id) +} + +func TestCreatePipelineSchedule_NoVariables(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusCreated, map[string]any{"id": 456}) + }) + + id, err := client.CreatePipelineSchedule(ctx, "myorg", "myrepo", "main", "Weekly", "0 0 * * 0", nil) + require.NoError(t, err) + assert.Equal(t, int64(456), id) +} + +func TestDeletePipelineSchedule(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules/123", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + err := client.DeletePipelineSchedule(ctx, "myorg", "myrepo", 123) + require.NoError(t, err) +} + +func TestListPipelineSchedules(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "id": int64(1), + "description": "Nightly", + "ref": "main", + "cron": "0 0 * * *", + "cron_timezone": "UTC", + "active": true, + }, + { + "id": int64(2), + "description": "Weekly", + "ref": "develop", + "cron": "0 0 * * 0", + "cron_timezone": "US/Pacific", + "active": false, + }, + }) + }) + + schedules, err := client.ListPipelineSchedules(ctx, "myorg", "myrepo") + require.NoError(t, err) + require.Len(t, schedules, 2) + + assert.Equal(t, int64(1), schedules[0].ID) + assert.Equal(t, "Nightly", schedules[0].Description) + assert.Equal(t, "main", schedules[0].Ref) + assert.Equal(t, "0 0 * * *", schedules[0].Cron) + assert.Equal(t, "UTC", schedules[0].CronTimezone) + assert.True(t, schedules[0].Active) + + assert.Equal(t, int64(2), schedules[1].ID) + assert.False(t, schedules[1].Active) +} + +func TestIsProtectedBranch_True(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/protected_branches/main", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]string{"name": "main"}) + }) + + protected, err := client.IsProtectedBranch(ctx, "myorg", "myrepo", "main") + require.NoError(t, err) + assert.True(t, protected) +} + +func TestIsProtectedBranch_False(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/protected_branches/feature", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + protected, err := client.IsProtectedBranch(ctx, "myorg", "myrepo", "feature") + require.NoError(t, err) + assert.False(t, protected) +} + +func TestIsProtectedBranch_UnexpectedStatus(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/protected_branches/main", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + _, err := client.IsProtectedBranch(ctx, "myorg", "myrepo", "main") + require.Error(t, err) +} + +func TestGetOrgPlan_WithPlan(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/namespaces/myorg", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + writeJSON(t, w, http.StatusOK, map[string]string{"plan": "premium"}) + }) + + plan, err := client.GetOrgPlan(ctx, "myorg") + require.NoError(t, err) + assert.Equal(t, "premium", plan) +} + +func TestGetOrgPlan_NoPlanField(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/namespaces/myorg", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]string{"plan": ""}) + }) + + plan, err := client.GetOrgPlan(ctx, "myorg") + require.NoError(t, err) + assert.Equal(t, "free", plan) +} + +func TestUpdateCIVariable(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/CI_VAR", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "new-val", body["value"]) + assert.Equal(t, true, body["protected"]) + writeJSON(t, w, http.StatusOK, map[string]any{"key": "CI_VAR", "value": "new-val"}) + }) + + err := client.UpdateCIVariable(ctx, "myorg", "myrepo", "CI_VAR", "new-val", true) + require.NoError(t, err) +} + +func TestCreateProtectedCIVariable(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "MY_CI_VAR", body["key"]) + assert.Equal(t, "ci-value", body["value"]) + assert.Equal(t, true, body["protected"]) + assert.Equal(t, false, body["masked"]) + assert.Equal(t, "env_var", body["variable_type"]) + + writeJSON(t, w, http.StatusCreated, map[string]any{"key": "MY_CI_VAR"}) + }) + + err := client.CreateProtectedCIVariable(ctx, "myorg", "myrepo", "MY_CI_VAR", "ci-value") + require.NoError(t, err) +} + +func TestErrNotSupported_OrgMethods(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + t.Run("CreateOrgSecret", func(t *testing.T) { + err := client.CreateOrgSecret(ctx, "org", "secret", "val", nil) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("OrgSecretExists", func(t *testing.T) { + _, err := client.OrgSecretExists(ctx, "org", "secret") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("DeleteOrgSecret", func(t *testing.T) { + err := client.DeleteOrgSecret(ctx, "org", "secret") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("SetOrgSecretRepos", func(t *testing.T) { + err := client.SetOrgSecretRepos(ctx, "org", "secret", nil) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetOrgSecretRepos", func(t *testing.T) { + _, err := client.GetOrgSecretRepos(ctx, "org", "secret") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("CreateOrUpdateOrgVariable", func(t *testing.T) { + err := client.CreateOrUpdateOrgVariable(ctx, "org", "var", "val", nil) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("CreateOrUpdateOrgVariableAll", func(t *testing.T) { + err := client.CreateOrUpdateOrgVariableAll(ctx, "org", "var", "val") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("OrgVariableExists", func(t *testing.T) { + _, err := client.OrgVariableExists(ctx, "org", "var") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetOrgVariable", func(t *testing.T) { + _, _, err := client.GetOrgVariable(ctx, "org", "var") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("ListOrgVariables", func(t *testing.T) { + _, err := client.ListOrgVariables(ctx, "org") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("DeleteOrgVariable", func(t *testing.T) { + err := client.DeleteOrgVariable(ctx, "org", "var") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("SetOrgVariableRepos", func(t *testing.T) { + err := client.SetOrgVariableRepos(ctx, "org", "var", nil) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetOrgVariableRepos", func(t *testing.T) { + _, err := client.GetOrgVariableRepos(ctx, "org", "var") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) +} + +func TestErrNotSupported_WorkflowMethods(t *testing.T) { + client, _ := setupTest(t) + ctx := context.Background() + + t.Run("GetWorkflow", func(t *testing.T) { + _, err := client.GetWorkflow(ctx, "o", "r", "w") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetLatestWorkflowRun", func(t *testing.T) { + _, err := client.GetLatestWorkflowRun(ctx, "o", "r", "w") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetWorkflowRun", func(t *testing.T) { + _, err := client.GetWorkflowRun(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("DispatchWorkflow", func(t *testing.T) { + err := client.DispatchWorkflow(ctx, "o", "r", "w", "main", nil) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("ListWorkflowRuns", func(t *testing.T) { + _, err := client.ListWorkflowRuns(ctx, "o", "r", "w") + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("ListRecentWorkflowRuns", func(t *testing.T) { + _, err := client.ListRecentWorkflowRuns(ctx, "o", "r", 10) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("ListWorkflowRunArtifacts", func(t *testing.T) { + _, err := client.ListWorkflowRunArtifacts(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("DownloadWorkflowRunArtifact", func(t *testing.T) { + _, err := client.DownloadWorkflowRunArtifact(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("ListRepositoryArtifacts", func(t *testing.T) { + _, err := client.ListRepositoryArtifacts(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetWorkflowRunLogs", func(t *testing.T) { + _, err := client.GetWorkflowRunLogs(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) + + t.Run("GetWorkflowRunAnnotations", func(t *testing.T) { + _, err := client.GetWorkflowRunAnnotations(ctx, "o", "r", 1) + require.ErrorIs(t, err, forge.ErrNotSupported) + }) +} + +// --------------------------------------------------------------------------- +// Additional edge-case and error-path tests +// --------------------------------------------------------------------------- + +func TestGitlabLabels_UnmarshalJSON(t *testing.T) { + t.Run("string array", func(t *testing.T) { + var l gitlabLabels + err := json.Unmarshal([]byte(`["bug","feature"]`), &l) + require.NoError(t, err) + assert.Equal(t, gitlabLabels{"bug", "feature"}, l) + }) + + t.Run("object array", func(t *testing.T) { + var l gitlabLabels + err := json.Unmarshal([]byte(`[{"title":"alpha"},{"title":"beta"}]`), &l) + require.NoError(t, err) + assert.Equal(t, gitlabLabels{"alpha", "beta"}, l) + }) + + t.Run("invalid format", func(t *testing.T) { + var l gitlabLabels + err := json.Unmarshal([]byte(`"not-an-array"`), &l) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected JSON format") + }) + + t.Run("empty array", func(t *testing.T) { + var l gitlabLabels + err := json.Unmarshal([]byte(`[]`), &l) + require.NoError(t, err) + assert.Equal(t, gitlabLabels{}, l) + }) +} + +func TestGitlabLabels_Strings(t *testing.T) { + t.Run("nil", func(t *testing.T) { + var l gitlabLabels + assert.Nil(t, l.strings()) + }) + + t.Run("non-nil", func(t *testing.T) { + l := gitlabLabels{"a", "b"} + assert.Equal(t, []string{"a", "b"}, l.strings()) + }) +} + +func TestDeleteRepoVariable(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_VAR", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + err := client.DeleteRepoVariable(ctx, "myorg", "myrepo", "MY_VAR") + require.NoError(t, err) +} + +func TestDeleteRepoVariable_Idempotent(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/GONE", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + err := client.DeleteRepoVariable(ctx, "myorg", "myrepo", "GONE") + require.NoError(t, err) +} + +func TestRepoVariableExists_True(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/EXISTS", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]string{"key": "EXISTS"}) + }) + + exists, err := client.RepoVariableExists(ctx, "myorg", "myrepo", "EXISTS") + require.NoError(t, err) + assert.True(t, exists) +} + +func TestRepoVariableExists_False(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MISSING", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + exists, err := client.RepoVariableExists(ctx, "myorg", "myrepo", "MISSING") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestDismissPullRequestReview_WithoutMessage(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "approved_by": []map[string]any{ + {"user": map[string]any{"id": 42, "username": "botuser"}}, + }, + }) + }) + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{"username": "botuser"}) + }) + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/unapprove", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusCreated, map[string]any{}) + }) + + // No notes endpoint registered -- if message is empty, no note should be posted + err := client.DismissPullRequestReview(ctx, "myorg", "myrepo", 10, -42, "") + require.NoError(t, err) +} + +func TestCreatePullRequestReview_RequestChanges(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var notes []string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/notes", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + readJSONBody(t, r, &body) + notes = append(notes, body["body"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"id": len(notes)}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "REQUEST_CHANGES", "Please fix", "sha", nil) + require.NoError(t, err) + require.Len(t, notes, 1) + assert.Contains(t, notes[0], "Please fix") + assert.Contains(t, notes[0], requestChangesMarker) +} + +func TestCreatePullRequestReview_RequestChangesEmptyBody(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + var notes []string + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/31/notes", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + readJSONBody(t, r, &body) + notes = append(notes, body["body"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"id": len(notes)}) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 31, "REQUEST_CHANGES", "", "sha", nil) + require.NoError(t, err) + require.Len(t, notes, 1) + assert.Equal(t, requestChangesMarker, notes[0]) +} + +func TestGetPullRequestInfo(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/5", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "iid": 5, + "web_url": "https://gitlab.com/myorg/myrepo/-/merge_requests/5", + "sha": "abc123", + "source_branch": "feature", + "target_branch": "main", + "author": map[string]any{"id": 42, "username": "contributor"}, + "source_project_id": 100, + "target_project_id": 100, + }) + }) + + info, err := client.GetPullRequestInfo(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) + assert.Equal(t, "myorg/myrepo", info.HeadRepo) + assert.Equal(t, "myorg/myrepo", info.BaseRepo) + assert.Equal(t, "contributor", info.AuthorID) + assert.False(t, info.IsFork) +} + +func TestGetPullRequestInfo_Fork(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/5", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "iid": 5, + "web_url": "https://gitlab.com/myorg/myrepo/-/merge_requests/5", + "sha": "abc123", + "source_branch": "feature", + "target_branch": "main", + "author": map[string]any{"id": 42, "username": "contributor"}, + "source_project_id": 200, + "target_project_id": 100, + }) + }) + + mux.HandleFunc("/api/v4/projects/200", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "path_with_namespace": "contributor/myrepo", + }) + }) + + info, err := client.GetPullRequestInfo(ctx, "myorg", "myrepo", 5) + require.NoError(t, err) + assert.Equal(t, "contributor/myrepo", info.HeadRepo) + assert.Equal(t, "myorg/myrepo", info.BaseRepo) + assert.True(t, info.IsFork) +} + +func TestDismissPullRequestReview_OtherApprover(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/10/approvals", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "approved_by": []map[string]any{ + {"user": map[string]any{"id": 42, "username": "otherapprover"}}, + }, + }) + }) + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{"username": "botuser"}) + }) + + err := client.DismissPullRequestReview(ctx, "myorg", "myrepo", 10, -42, "outdated") + require.Error(t, err) + assert.ErrorIs(t, err, forge.ErrNotSupported) +} + +func TestCreatePullRequestReview_Approve_409SHAMismatch(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/approve", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"The SHA does not match"}`) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "APPROVE", "", "stale-sha", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "409 Conflict") +} + +func TestCreatePullRequestReview_Approve_409AlreadyApproved(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/approve", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"You have already approved this merge request"}`) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "APPROVE", "", "sha123", nil) + require.NoError(t, err) +} + +func TestCreatePullRequestReview_Approve_409AlreadyMerged(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests/30/approve", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"MR has already been merged"}`) + }) + + err := client.CreatePullRequestReview(ctx, "myorg", "myrepo", 30, "APPROVE", "", "sha123", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "409 Conflict") +} + +func TestCreateRepoSecret_MaskedFallback(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + callCount := 0 + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + callCount++ + var body map[string]any + readJSONBody(t, r, &body) + if body["masked"] == true { + writeJSON(t, w, http.StatusBadRequest, map[string]string{ + "message": "This variable can not be masked", + }) + return + } + assert.Equal(t, false, body["masked"]) + writeJSON(t, w, http.StatusCreated, map[string]any{"key": "SHORT"}) + }) + + err := client.CreateRepoSecret(ctx, "myorg", "myrepo", "SHORT", "ab") + require.NoError(t, err) + assert.Equal(t, 2, callCount, "should retry with masked:false after 400") +} + +func TestCreateRepoSecret_Upsert(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + callCount := 0 + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + callCount++ + writeJSON(t, w, http.StatusConflict, map[string]string{ + "message": "MY_SECRET has already been taken", + }) + }) + + updated := false + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables/MY_SECRET", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + var body map[string]any + readJSONBody(t, r, &body) + assert.Equal(t, "newvalue", body["value"]) + assert.Equal(t, true, body["protected"]) + updated = true + writeJSON(t, w, http.StatusOK, map[string]any{"key": "MY_SECRET"}) + }) + + err := client.CreateRepoSecret(ctx, "myorg", "myrepo", "MY_SECRET", "newvalue") + require.NoError(t, err) + assert.Equal(t, 1, callCount) + assert.True(t, updated) +} + +func TestCreateRepoSecret_MaskedFallbackNotOnNonMaskError(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/variables", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusBadRequest, map[string]string{ + "message": "key is invalid", + }) + }) + + err := client.CreateRepoSecret(ctx, "myorg", "myrepo", "BAD_KEY!", "value") + require.Error(t, err) + assert.Contains(t, err.Error(), "key is invalid") +} + +func TestCreatePipelineSchedule_CleansUpOnVariableFailure(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + scheduleDeleted := false + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + writeJSON(t, w, http.StatusCreated, map[string]any{"id": 77}) + return + } + }) + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules/77/variables", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusBadRequest, map[string]string{"message": "invalid"}) + }) + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/pipeline_schedules/77", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + scheduleDeleted = true + w.WriteHeader(http.StatusNoContent) + } + }) + + id, err := client.CreatePipelineSchedule(ctx, "myorg", "myrepo", "main", "test", "0 * * * *", map[string]string{"VAR": "val"}) + require.Error(t, err) + assert.Equal(t, int64(0), id) + assert.True(t, scheduleDeleted, "orphaned schedule should be cleaned up") +} + +func TestListOrgRepos_ExcludesInternal(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/groups/myorg/projects", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{ + {"id": 1, "name": "public-proj", "path_with_namespace": "myorg/public-proj", + "default_branch": "main", "visibility": "public", "archived": false}, + {"id": 2, "name": "internal-proj", "path_with_namespace": "myorg/internal-proj", + "default_branch": "main", "visibility": "internal", "archived": false}, + {"id": 3, "name": "private-proj", "path_with_namespace": "myorg/private-proj", + "default_branch": "main", "visibility": "private", "archived": false}, + }) + }) + + repos, err := client.ListOrgRepos(ctx, "myorg") + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "myorg/public-proj", repos[0].FullName) +} + +func TestListOrgRepos_IncludesSubgroups(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/groups/myorg/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "true", r.URL.Query().Get("include_subgroups")) + writeJSON(t, w, http.StatusOK, []map[string]any{ + { + "id": 1, "name": "sub-project", "path_with_namespace": "myorg/sub/sub-project", + "default_branch": "main", "visibility": "public", "archived": false, + }, + }) + }) + + repos, err := client.ListOrgRepos(ctx, "myorg") + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "myorg/sub/sub-project", repos[0].FullName) +} + +func TestCreateChangeProposal_Error(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/myorg%2Fmyrepo/merge_requests", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusUnprocessableEntity, map[string]string{ + "message": "Validation failed", + }) + }) + + _, err := client.CreateChangeProposal(ctx, "myorg", "myrepo", "Title", "Body", "head", "base") + require.Error(t, err) + assert.Contains(t, err.Error(), "Validation failed") +} + +func TestGetAuthenticatedUser_Error(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, map[string]string{ + "message": "401 Unauthorized", + }) + }) + + _, err := client.GetAuthenticatedUser(ctx) + require.Error(t, err) +} + +func TestGetAuthenticatedUserIdentity_Fallbacks(t *testing.T) { + t.Run("name and email present", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": 42, "username": "jdoe", "name": "Jane Doe", "email": "jane@example.com", + }) + }) + id, err := client.GetAuthenticatedUserIdentity(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Jane Doe", id.Name) + assert.Equal(t, "jane@example.com", id.Email) + }) + + t.Run("empty name falls back to username", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": 42, "username": "jdoe", "name": "", "email": "jane@example.com", + }) + }) + id, err := client.GetAuthenticatedUserIdentity(context.Background()) + require.NoError(t, err) + assert.Equal(t, "jdoe", id.Name) + }) + + t.Run("empty email falls back to noreply using baseURL host", func(t *testing.T) { + client, mux := setupTest(t) + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": 42, "username": "jdoe", "name": "Jane Doe", "email": "", + }) + }) + id, err := client.GetAuthenticatedUserIdentity(context.Background()) + require.NoError(t, err) + assert.Contains(t, id.Email, "42+jdoe@users.noreply.") + }) + + t.Run("empty email uses gitlab.com domain for default baseURL", func(t *testing.T) { + client, err := New("test-token") + require.NoError(t, err) + // Can't call the API on real gitlab.com, but verify the baseURL hostname logic + u, _ := url.Parse(client.baseURL) + assert.Equal(t, "gitlab.com", u.Hostname()) + }) +} + +func TestCreateChangeProposal_NoChanges(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/merge_requests", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusConflict, map[string]string{ + "message": "No commits between base and head", + }) + }) + + _, err := client.CreateChangeProposal(ctx, "owner", "repo", "Title", "Body", "head", "base") + require.Error(t, err) + assert.True(t, forge.IsNoChanges(err), "expected ErrNoChanges, got: %v", err) +} + +func TestCreateBranch_AlreadyExists400(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/owner%2Frepo", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": 1, "name": "repo", "path_with_namespace": "owner/repo", + "default_branch": "main", "visibility": "public", + }) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/branches", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusBadRequest, map[string]string{ + "message": "Branch already exists", + }) + }) + + err := client.CreateBranch(ctx, "owner", "repo", "feature") + require.Error(t, err) + assert.True(t, forge.IsAlreadyExists(err), "expected ErrAlreadyExists, got: %v", err) +} + +func TestCommitFilesImpl_BranchProtected(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{}) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusForbidden, map[string]string{ + "message": "You are not allowed to push into this branch. Branch is protected.", + }) + }) + + _, err := client.CommitFilesToBranch(ctx, "owner", "repo", "main", "test", []forge.TreeFile{ + {Path: "new.txt", Content: []byte("hello"), Mode: "100644"}, + }) + require.Error(t, err) + assert.True(t, forge.IsBranchProtected(err), "expected ErrBranchProtected, got: %v", err) +} + +func TestCommitFilesImpl_NonFastForward(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/tree", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{}) + }) + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/repository/commits", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusConflict, map[string]string{ + "message": "Could not update refs/heads/main. Please refresh and try again.", + }) + }) + + _, err := client.CommitFilesToBranch(ctx, "owner", "repo", "main", "test", []forge.TreeFile{ + {Path: "new.txt", Content: []byte("hello"), Mode: "100644"}, + }) + require.Error(t, err) + assert.True(t, forge.IsNonFastForward(err), "expected ErrNonFastForward, got: %v", err) +} + +func TestListOpenIssues_LabelEncoding(t *testing.T) { + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/api/v4/projects/owner%2Frepo/issues", func(w http.ResponseWriter, r *http.Request) { + labels := r.URL.Query().Get("labels") + assert.Equal(t, "bug fix,feature&request", labels) + writeJSON(t, w, http.StatusOK, []map[string]any{}) + }) + + _, err := client.ListOpenIssues(ctx, "owner", "repo", "bug fix", "feature&request") + require.NoError(t, err) +} diff --git a/internal/forge/gitlab/mr.go b/internal/forge/gitlab/mr.go new file mode 100644 index 0000000000..2ccbf58bd6 --- /dev/null +++ b/internal/forge/gitlab/mr.go @@ -0,0 +1,564 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +const requestChangesMarker = "" + +// CreateChangeProposal creates a merge request on GitLab. +func (c *LiveClient) CreateChangeProposal(ctx context.Context, owner, repo, title, body, head, base string) (*forge.ChangeProposal, error) { + path := fmt.Sprintf("/projects/%s/merge_requests", projectPath(owner, repo)) + payload := map[string]string{ + "source_branch": head, + "target_branch": base, + "title": title, + "description": body, + } + + resp, err := c.post(ctx, path, payload) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + msg := strings.ToLower(apiErr.Message) + if strings.Contains(msg, "no commits") || strings.Contains(msg, "no changes") { + return nil, fmt.Errorf("create merge request: %w: %w", forge.ErrNoChanges, err) + } + } + return nil, fmt.Errorf("create merge request: %w", err) + } + + var mr struct { + IID int `json:"iid"` + Title string `json:"title"` + WebURL string `json:"web_url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + } + if err := decodeJSON(resp, &mr); err != nil { + return nil, fmt.Errorf("decode merge request: %w", err) + } + + return &forge.ChangeProposal{ + Number: mr.IID, + URL: mr.WebURL, + Title: mr.Title, + Head: mr.SourceBranch, + Base: mr.TargetBranch, + }, nil +} + +// ListRepoPullRequests lists open merge requests for a project with pagination. +func (c *LiveClient) ListRepoPullRequests(ctx context.Context, owner, repo string) ([]forge.ChangeProposal, error) { + var result []forge.ChangeProposal + + for page := 1; page <= 100; page++ { + path := fmt.Sprintf("/projects/%s/merge_requests?state=opened&per_page=100&page=%d", + projectPath(owner, repo), page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list merge requests page %d: %w", page, err) + } + + var mrs []struct { + IID int `json:"iid"` + Title string `json:"title"` + WebURL string `json:"web_url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + } + if err := decodeJSON(resp, &mrs); err != nil { + return nil, fmt.Errorf("decode merge requests page %d: %w", page, err) + } + + for _, mr := range mrs { + result = append(result, forge.ChangeProposal{ + Number: mr.IID, + URL: mr.WebURL, + Title: mr.Title, + Head: mr.SourceBranch, + Base: mr.TargetBranch, + }) + } + + if len(mrs) < 100 { + break + } + } + + return result, nil +} + +// GetPullRequestInfo returns branch and repo context for a merge request. +func (c *LiveClient) GetPullRequestInfo(ctx context.Context, owner, repo string, number int) (*forge.PullRequestInfo, error) { + path := fmt.Sprintf("/projects/%s/merge_requests/%d", projectPath(owner, repo), number) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("get merge request !%d: %w", number, err) + } + + var mr struct { + IID int `json:"iid"` + WebURL string `json:"web_url"` + SHA string `json:"sha"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Author struct { + ID int `json:"id"` + Username string `json:"username"` + } `json:"author"` + SourceProjectID int `json:"source_project_id"` + TargetProjectID int `json:"target_project_id"` + } + if err := decodeJSON(resp, &mr); err != nil { + return nil, fmt.Errorf("decode merge request !%d: %w", number, err) + } + + headRepo := owner + "/" + repo + isFork := mr.SourceProjectID != mr.TargetProjectID + if isFork { + srcPath := fmt.Sprintf("/projects/%d", mr.SourceProjectID) + srcResp, err := c.get(ctx, srcPath) + if err != nil { + return nil, fmt.Errorf("get source project for !%d: %w", number, err) + } + var srcProj struct { + PathWithNamespace string `json:"path_with_namespace"` + } + if err := decodeJSON(srcResp, &srcProj); err != nil { + return nil, fmt.Errorf("decode source project for !%d: %w", number, err) + } + headRepo = srcProj.PathWithNamespace + } + + return &forge.PullRequestInfo{ + Number: mr.IID, + HTMLURL: mr.WebURL, + HeadRepo: headRepo, + BaseRepo: owner + "/" + repo, + HeadRef: mr.SourceBranch, + BaseRef: mr.TargetBranch, + HeadSHA: mr.SHA, + AuthorID: mr.Author.Username, + IsFork: isFork, + }, nil +} + +// GetPullRequestHeadSHA returns the current HEAD commit SHA of a merge request. +func (c *LiveClient) GetPullRequestHeadSHA(ctx context.Context, owner, repo string, number int) (string, error) { + path := fmt.Sprintf("/projects/%s/merge_requests/%d", projectPath(owner, repo), number) + resp, err := c.get(ctx, path) + if err != nil { + return "", fmt.Errorf("get merge request !%d: %w", number, err) + } + + var mr struct { + SHA string `json:"sha"` + } + if err := decodeJSON(resp, &mr); err != nil { + return "", fmt.Errorf("decode merge request !%d: %w", number, err) + } + return mr.SHA, nil +} + +// ListPullRequestFiles returns the file paths changed by a merge request. +// GitLab returns diffs with old_path and new_path; we use new_path as the +// canonical path (matching rename destinations). +func (c *LiveClient) ListPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]string, error) { + var files []string + + for page := 1; page <= 100; page++ { + path := fmt.Sprintf("/projects/%s/merge_requests/%d/diffs?per_page=100&page=%d", + projectPath(owner, repo), number, page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list merge request diffs page %d: %w", page, err) + } + + var diffs []struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + } + if err := decodeJSON(resp, &diffs); err != nil { + return nil, fmt.Errorf("decode merge request diffs page %d: %w", page, err) + } + + for _, d := range diffs { + files = append(files, d.NewPath) + } + + if len(diffs) < 100 { + break + } + } + + return files, nil +} + +// ListPullRequestFileDiffs returns the files changed by a merge request +// along with their unified diff patches. +func (c *LiveClient) ListPullRequestFileDiffs(ctx context.Context, owner, repo string, number int) ([]forge.PullRequestFileDiff, error) { + var files []forge.PullRequestFileDiff + + for page := 1; page <= 100; page++ { + path := fmt.Sprintf("/projects/%s/merge_requests/%d/diffs?per_page=100&page=%d", + projectPath(owner, repo), number, page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list merge request file diffs page %d: %w", page, err) + } + + var diffs []struct { + NewPath string `json:"new_path"` + Diff string `json:"diff"` + } + if err := decodeJSON(resp, &diffs); err != nil { + return nil, fmt.Errorf("decode merge request file diffs page %d: %w", page, err) + } + + for _, d := range diffs { + files = append(files, forge.PullRequestFileDiff{ + Path: d.NewPath, + Patch: d.Diff, + }) + } + + if len(diffs) < 100 { + break + } + } + + return files, nil +} + +// MergeChangeProposal merges a merge request by its IID. +func (c *LiveClient) MergeChangeProposal(ctx context.Context, owner, repo string, number int) error { + path := fmt.Sprintf("/projects/%s/merge_requests/%d/merge", projectPath(owner, repo), number) + resp, err := c.put(ctx, path, nil) + if err != nil { + return fmt.Errorf("merge merge request !%d: %w", number, err) + } + resp.Body.Close() + return nil +} + +// UpdatePullRequestBranch rebases a merge request's source branch onto +// the target branch. GitLab uses rebase rather than merge-base-into-head. +// The rebase may be asynchronous; we fire the request and return without +// waiting for completion. +func (c *LiveClient) UpdatePullRequestBranch(ctx context.Context, owner, repo string, number int) error { + path := fmt.Sprintf("/projects/%s/merge_requests/%d/rebase", projectPath(owner, repo), number) + resp, err := c.do(ctx, http.MethodPut, path, nil) + if err != nil { + return fmt.Errorf("rebase merge request !%d: %w", number, err) + } + defer resp.Body.Close() + if err := checkStatus(resp, http.StatusOK, http.StatusAccepted); err != nil { + return fmt.Errorf("rebase merge request !%d: %w", number, err) + } + return nil +} + +// CreatePullRequestReview creates a review on a merge request. +// +// GitLab has no native review object. This method synthesizes reviews: +// - APPROVE: POST /projects/:id/merge_requests/:iid/approve +// When commitSHA is non-empty, it is passed as the "sha" parameter +// so GitLab rejects the approval if HEAD has advanced (409 Conflict). +// - REQUEST_CHANGES or COMMENT: POST a note with the review body, +// plus individual notes for each inline comment. +// GitLab's Notes API has no commit-pinning parameter, so commitSHA +// cannot be enforced for these events. +// +// Inline comments are posted as plain MR notes with file:line in the body +// text, not as positioned diff comments. GitLab's Discussions API supports +// positioned comments but requires base/head/start SHAs that are not +// available through the forge.Client interface. +func (c *LiveClient) CreatePullRequestReview(ctx context.Context, owner, repo string, number int, event, body, commitSHA string, comments []forge.ReviewComment) error { + proj := projectPath(owner, repo) + + switch event { + case "APPROVE": + approvePath := fmt.Sprintf("/projects/%s/merge_requests/%d/approve", proj, number) + approveBody := map[string]string{} + if commitSHA != "" { + approveBody["sha"] = commitSHA + } + resp, err := c.do(ctx, http.MethodPost, approvePath, approveBody) + if err != nil { + return fmt.Errorf("approve merge request !%d: %w", number, err) + } + if resp.StatusCode == http.StatusConflict { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + resp.Body.Close() + msg := extractConflictMessage(data) + if strings.Contains(strings.ToLower(msg), "already approved") { + // Idempotent: already approved (undocumented 409 variant). + } else { + return fmt.Errorf("approve merge request !%d: 409 Conflict: %s", number, msg) + } + } else if err := checkStatus(resp, http.StatusOK, http.StatusCreated); err != nil { + return fmt.Errorf("approve merge request !%d: %w", number, err) + } else { + resp.Body.Close() + } + + // If there is also a body, post it as a note. + if body != "" { + notePath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes", proj, number) + noteResp, err := c.post(ctx, notePath, map[string]string{"body": body}) + if err != nil { + return fmt.Errorf("post approval comment on !%d: %w", number, err) + } + noteResp.Body.Close() + } + + case "REQUEST_CHANGES", "COMMENT": + noteBody := body + if event == "REQUEST_CHANGES" { + if noteBody != "" { + noteBody = requestChangesMarker + "\n\n" + noteBody + } else { + noteBody = requestChangesMarker + } + } + if noteBody != "" { + notePath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes", proj, number) + resp, err := c.post(ctx, notePath, map[string]string{"body": noteBody}) + if err != nil { + return fmt.Errorf("post review comment on !%d: %w", number, err) + } + resp.Body.Close() + } + + default: + return fmt.Errorf("create review on !%d: invalid event %q", number, event) + } + + // Post inline comments as individual notes referencing file and line. + for _, rc := range comments { + notePath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes", proj, number) + noteBody := rc.Body + if rc.Line > 0 { + noteBody = fmt.Sprintf("`%s:%d`\n\n%s", rc.Path, rc.Line, rc.Body) + } else { + noteBody = fmt.Sprintf("`%s`\n\n%s", rc.Path, rc.Body) + } + resp, err := c.post(ctx, notePath, map[string]string{"body": noteBody}) + if err != nil { + return fmt.Errorf("post inline comment on !%d (%s:%d): %w", number, rc.Path, rc.Line, err) + } + resp.Body.Close() + } + + return nil +} + +// ListPullRequestReviews synthesizes reviews from GitLab's approval state +// and MR notes. +// +// GitLab has no native review object. Approvals are mapped to APPROVED +// reviews (ID = approver's user ID), and MR notes are mapped to COMMENTED +// reviews (ID = note ID). DismissPullRequestReview relies on this convention +// to distinguish approvals from comments. +func (c *LiveClient) ListPullRequestReviews(ctx context.Context, owner, repo string, number int) ([]forge.PullRequestReview, error) { + proj := projectPath(owner, repo) + var result []forge.PullRequestReview + + // 1. Get approvals via the approvals endpoint (works on all tiers, + // unlike approval_state which requires Premium for rules). + approvalPath := fmt.Sprintf("/projects/%s/merge_requests/%d/approvals", proj, number) + approvalResp, err := c.get(ctx, approvalPath) + if err != nil { + return nil, fmt.Errorf("get approvals for !%d: %w", number, err) + } + + var approvals struct { + ApprovedBy []struct { + User struct { + ID int `json:"id"` + Username string `json:"username"` + } `json:"user"` + } `json:"approved_by"` + } + if err := decodeJSON(approvalResp, &approvals); err != nil { + return nil, fmt.Errorf("decode approvals for !%d: %w", number, err) + } + + for _, entry := range approvals.ApprovedBy { + result = append(result, forge.PullRequestReview{ + ID: -entry.User.ID, + User: entry.User.Username, + State: "APPROVED", + }) + } + + // 2. Get notes (comments) on the MR. + for page := 1; page <= 100; page++ { + notesPath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes?per_page=100&page=%d&sort=asc", + proj, number, page) + notesResp, err := c.get(ctx, notesPath) + if err != nil { + return nil, fmt.Errorf("list notes for !%d page %d: %w", number, page, err) + } + + var notes []struct { + ID int `json:"id"` + Body string `json:"body"` + System bool `json:"system"` + CreatedAt string `json:"created_at"` + Author struct { + ID int `json:"id"` + Username string `json:"username"` + } `json:"author"` + } + if err := decodeJSON(notesResp, ¬es); err != nil { + return nil, fmt.Errorf("decode notes for !%d page %d: %w", number, page, err) + } + + for _, note := range notes { + if note.System { + continue + } + state := "COMMENTED" + if strings.Contains(note.Body, requestChangesMarker) { + state = "CHANGES_REQUESTED" + } + result = append(result, forge.PullRequestReview{ + ID: note.ID, + User: note.Author.Username, + State: state, + Body: note.Body, + SubmittedAt: note.CreatedAt, + }) + } + + if len(notes) < 100 { + break + } + } + + return result, nil +} + +// DismissPullRequestReview dismisses a review on a merge request. +// +// On GitLab, if the review was an approval, this unapproves the MR. +// For non-approval reviews backed by a request-changes note, this edits +// the note to replace the request-changes marker with a dismissed marker +// so ListPullRequestReviews stops reporting it as CHANGES_REQUESTED. +func (c *LiveClient) DismissPullRequestReview(ctx context.Context, owner, repo string, number, reviewID int, message string) error { + // Check if this reviewID corresponds to an approver by looking up + // the approval state. The reviewID for approvals is the user ID. + proj := projectPath(owner, repo) + + approvalPath := fmt.Sprintf("/projects/%s/merge_requests/%d/approvals", proj, number) + approvalResp, err := c.get(ctx, approvalPath) + if err != nil { + return fmt.Errorf("get approvals for !%d: %w", number, err) + } + + var approvals struct { + ApprovedBy []struct { + User struct { + ID int `json:"id"` + Username string `json:"username"` + } `json:"user"` + } `json:"approved_by"` + } + if err := decodeJSON(approvalResp, &approvals); err != nil { + return fmt.Errorf("decode approvals for !%d: %w", number, err) + } + + isApproval := false + var approverUsername string + if reviewID < 0 { + userID := -reviewID + for _, entry := range approvals.ApprovedBy { + if entry.User.ID == userID { + isApproval = true + approverUsername = entry.User.Username + break + } + } + } + + if !isApproval { + // The reviewID may be a note ID for a CHANGES_REQUESTED review. + // Edit the note to remove the request-changes marker so + // ListPullRequestReviews stops reporting it as CHANGES_REQUESTED. + return c.dismissRequestChangesNote(ctx, proj, number, reviewID, message) + } + + // GitLab's /unapprove always removes the authenticated user's approval, + // not a specific reviewer's. Verify the reviewID matches our user. + authUser, err := c.GetAuthenticatedUser(ctx) + if err != nil { + return fmt.Errorf("get authenticated user for dismiss: %w", err) + } + if approverUsername != authUser { + return fmt.Errorf("dismiss approval on !%d: %w: can only unapprove the authenticated user's own approval", number, forge.ErrNotSupported) + } + + unapprovePath := fmt.Sprintf("/projects/%s/merge_requests/%d/unapprove", proj, number) + resp, err := c.post(ctx, unapprovePath, nil) + if err != nil { + return fmt.Errorf("unapprove merge request !%d: %w", number, err) + } + resp.Body.Close() + + // If a dismiss message was provided, post it as a note. + if message != "" { + notePath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes", proj, number) + noteResp, err := c.post(ctx, notePath, map[string]string{ + "body": "Review dismissed: " + message, + }) + if err != nil { + return fmt.Errorf("post dismiss message on !%d: %w", number, err) + } + noteResp.Body.Close() + } + + return nil +} + +const dismissedMarker = "" + +// dismissRequestChangesNote edits a request-changes note to replace the +// marker with a dismissed marker so ListPullRequestReviews stops reporting +// it as CHANGES_REQUESTED. +func (c *LiveClient) dismissRequestChangesNote(ctx context.Context, proj string, mrIID, noteID int, message string) error { + notePath := fmt.Sprintf("/projects/%s/merge_requests/%d/notes/%d", proj, mrIID, noteID) + resp, err := c.get(ctx, notePath) + if err != nil { + return fmt.Errorf("get note %d on !%d: %w", noteID, mrIID, err) + } + var note struct { + Body string `json:"body"` + } + if err := decodeJSON(resp, ¬e); err != nil { + return fmt.Errorf("decode note %d on !%d: %w", noteID, mrIID, err) + } + + if !strings.Contains(note.Body, requestChangesMarker) { + return nil + } + + newBody := strings.Replace(note.Body, requestChangesMarker, dismissedMarker, 1) + if message != "" { + newBody += "\n\n_Dismissed: " + message + "_" + } + putResp, err := c.put(ctx, notePath, map[string]string{"body": newBody}) + if err != nil { + return fmt.Errorf("dismiss note %d on !%d: %w", noteID, mrIID, err) + } + putResp.Body.Close() + return nil +} diff --git a/internal/forge/gitlab/repo.go b/internal/forge/gitlab/repo.go new file mode 100644 index 0000000000..cc151e5ddd --- /dev/null +++ b/internal/forge/gitlab/repo.go @@ -0,0 +1,756 @@ +package gitlab + +import ( + "context" + "crypto/sha1" //nolint:gosec // Git's blob hash algorithm, not used for security + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// maxTreePages is the pagination safety bound for tree-listing endpoints +// (getTreeMap, ListDirectoryContents, ListRepositoryFiles). Set higher than +// the entity-listing cap (100 pages) because file trees can have orders of +// magnitude more entries in monorepos. +const maxTreePages = 1000 + +type treeEntry struct { + sha string + mode string +} + +func blobSHA(content []byte) string { + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(content)) + h.Write(content) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func (c *LiveClient) getDefaultBranch(ctx context.Context, owner, repo string) (string, error) { + r, err := c.GetRepo(ctx, owner, repo) + if err != nil { + return "", err + } + return r.DefaultBranch, nil +} + +func (c *LiveClient) getTreeMap(ctx context.Context, owner, repo, ref string) (map[string]treeEntry, error) { + proj := projectPath(owner, repo) + result := make(map[string]treeEntry) + + for page := 1; page <= maxTreePages; page++ { + apiPath := fmt.Sprintf("/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", + proj, url.QueryEscape(ref), page) + resp, err := c.get(ctx, apiPath) + if err != nil { + if forge.IsNotFound(err) { + return result, nil + } + return nil, err + } + + nextPage := resp.Header.Get("X-Next-Page") + + var entries []struct { + ID string `json:"id"` + Path string `json:"path"` + Type string `json:"type"` + Mode string `json:"mode"` + } + if err := decodeJSON(resp, &entries); err != nil { + return nil, err + } + + for _, e := range entries { + if e.Type == "blob" { + result[e.Path] = treeEntry{sha: e.ID, mode: e.Mode} + } + } + + if nextPage == "" || len(entries) < 100 { + break + } + } + + return result, nil +} + +func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repository, error) { + var result []forge.Repository + + for page := 1; page <= 100; page++ { + apiPath := fmt.Sprintf("/groups/%s/projects?per_page=100&page=%d&archived=false&with_shared=false&include_subgroups=true", + url.PathEscape(org), page) + resp, err := c.get(ctx, apiPath) + if err != nil { + return nil, fmt.Errorf("list group projects page %d: %w", page, err) + } + + var projects []struct { + ID int64 `json:"id"` + Name string `json:"name"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + Archived bool `json:"archived"` + ForkedFromProject any `json:"forked_from_project"` + } + if err := decodeJSON(resp, &projects); err != nil { + return nil, fmt.Errorf("decode group projects page %d: %w", page, err) + } + + for _, p := range projects { + if p.Archived || p.ForkedFromProject != nil || p.Visibility != "public" { + continue + } + result = append(result, forge.Repository{ + ID: p.ID, + Name: p.Name, + FullName: p.PathWithNamespace, + DefaultBranch: p.DefaultBranch, + Private: false, + Archived: false, + Fork: false, + }) + } + + if len(projects) < 100 { + break + } + } + + return result, nil +} + +func (c *LiveClient) GetRepo(ctx context.Context, owner, repo string) (*forge.Repository, error) { + proj := projectPath(owner, repo) + resp, err := c.get(ctx, fmt.Sprintf("/projects/%s", proj)) + if err != nil { + return nil, fmt.Errorf("get repo %s/%s: %w", owner, repo, err) + } + + var p struct { + ID int64 `json:"id"` + Name string `json:"name"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + Archived bool `json:"archived"` + ForkedFromProject any `json:"forked_from_project"` + } + if err := decodeJSON(resp, &p); err != nil { + return nil, fmt.Errorf("decode repo: %w", err) + } + + return &forge.Repository{ + ID: p.ID, + Name: p.Name, + FullName: p.PathWithNamespace, + DefaultBranch: p.DefaultBranch, + Private: p.Visibility != "public", + Archived: p.Archived, + Fork: p.ForkedFromProject != nil, + }, nil +} + +func (c *LiveClient) CreateRepo(ctx context.Context, org, name, description string, private bool) (*forge.Repository, error) { + groupResp, err := c.get(ctx, fmt.Sprintf("/groups/%s", url.PathEscape(org))) + if err != nil { + return nil, fmt.Errorf("get group %s: %w", org, err) + } + var group struct { + ID int64 `json:"id"` + } + if err := decodeJSON(groupResp, &group); err != nil { + return nil, fmt.Errorf("decode group: %w", err) + } + + visibility := "public" + if private { + visibility = "private" + } + + payload := map[string]any{ + "name": name, + "namespace_id": group.ID, + "description": description, + "visibility": visibility, + "initialize_with_readme": true, + } + + resp, err := c.post(ctx, "/projects", payload) + if err != nil { + return nil, fmt.Errorf("create repo: %w", err) + } + + var p struct { + ID int64 `json:"id"` + Name string `json:"name"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + } + if err := decodeJSON(resp, &p); err != nil { + return nil, fmt.Errorf("decode create repo response: %w", err) + } + + return &forge.Repository{ + ID: p.ID, + Name: p.Name, + FullName: p.PathWithNamespace, + DefaultBranch: p.DefaultBranch, + Private: p.Visibility != "public", + }, nil +} + +func (c *LiveClient) DeleteRepo(ctx context.Context, owner, repo string) error { + return c.delete_(ctx, fmt.Sprintf("/projects/%s", projectPath(owner, repo))) +} + +func (c *LiveClient) FindExistingFork(ctx context.Context, owner, repo string) (string, string, error) { + proj := projectPath(owner, repo) + resp, err := c.get(ctx, fmt.Sprintf("/projects/%s/forks?owned=true&per_page=1", proj)) + if err != nil { + return "", "", fmt.Errorf("find existing fork of %s/%s: %w", owner, repo, err) + } + + var forks []struct { + Path string `json:"path"` + Namespace struct { + FullPath string `json:"full_path"` + } `json:"namespace"` + } + if err := decodeJSON(resp, &forks); err != nil { + return "", "", fmt.Errorf("decode forks: %w", err) + } + + if len(forks) == 0 { + return "", "", nil + } + return forks[0].Namespace.FullPath, forks[0].Path, nil +} + +// CreateFork is idempotent: if a fork already exists (409 Conflict), +// it returns the existing fork's metadata. +func (c *LiveClient) CreateFork(ctx context.Context, owner, repo string) (string, string, error) { + proj := projectPath(owner, repo) + resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/projects/%s/fork", proj), map[string]any{}) + if err != nil { + return "", "", fmt.Errorf("create fork of %s/%s: %w", owner, repo, err) + } + + if resp.StatusCode == http.StatusConflict { + resp.Body.Close() + forkOwner, forkRepo, err := c.FindExistingFork(ctx, owner, repo) + if err != nil { + return "", "", err + } + if forkOwner == "" { + return "", "", fmt.Errorf("create fork of %s/%s: 409 Conflict but no existing fork found", owner, repo) + } + return forkOwner, forkRepo, nil + } + + if err := checkStatus(resp, http.StatusOK, http.StatusCreated); err != nil { + return "", "", fmt.Errorf("create fork of %s/%s: %w", owner, repo, err) + } + + var fork struct { + Path string `json:"path"` + Namespace struct { + FullPath string `json:"full_path"` + } `json:"namespace"` + } + if err := decodeJSON(resp, &fork); err != nil { + return "", "", fmt.Errorf("decode fork response: %w", err) + } + return fork.Namespace.FullPath, fork.Path, nil +} + +// CreateForkInOrg creates a fork of the given repository under the specified +// GitLab group (namespace) with the given name. +func (c *LiveClient) CreateForkInOrg(ctx context.Context, owner, repo, org, forkName string) (string, error) { + proj := projectPath(owner, repo) + body := map[string]string{ + "namespace_path": org, + "name": forkName, + "path": forkName, + } + resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/projects/%s/fork", proj), body) + if err != nil { + return "", fmt.Errorf("create fork of %s/%s in %s: %w", owner, repo, org, err) + } + + if resp.StatusCode == http.StatusConflict { + resp.Body.Close() + // Check if the existing repo is actually a fork of the source. + existingPath := fmt.Sprintf("/projects/%s", projectPath(org, forkName)) + existingResp, err := c.get(ctx, existingPath) + if err != nil { + return "", fmt.Errorf("check existing repo %s/%s: %w", org, forkName, err) + } + var existing struct { + Path string `json:"path"` + ForkedFromProject *struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"forked_from_project"` + } + if err := decodeJSON(existingResp, &existing); err != nil { + return "", fmt.Errorf("decode existing repo: %w", err) + } + sourcePath := owner + "/" + repo + if existing.ForkedFromProject == nil || !strings.EqualFold(existing.ForkedFromProject.PathWithNamespace, sourcePath) { + return "", forge.ErrNotFork + } + return existing.Path, nil + } + + if err := checkStatus(resp, http.StatusOK, http.StatusCreated); err != nil { + return "", fmt.Errorf("create fork of %s/%s in %s: %w", owner, repo, org, err) + } + + var fork struct { + Path string `json:"path"` + } + if err := decodeJSON(resp, &fork); err != nil { + return "", fmt.Errorf("decode fork response: %w", err) + } + return fork.Path, nil +} + +func (c *LiveClient) GetBranchRef(ctx context.Context, owner, repo, branch string) (string, error) { + proj := projectPath(owner, repo) + resp, err := c.get(ctx, fmt.Sprintf("/projects/%s/repository/branches/%s", proj, url.PathEscape(branch))) + if err != nil { + return "", fmt.Errorf("get branch ref %s/%s@%s: %w", owner, repo, branch, err) + } + var b struct { + Commit struct { + ID string `json:"id"` + } `json:"commit"` + } + if err := decodeJSON(resp, &b); err != nil { + return "", fmt.Errorf("decode branch: %w", err) + } + return b.Commit.ID, nil +} + +func (c *LiveClient) CreateBranch(ctx context.Context, owner, repo, branchName string) error { + defaultBranch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return fmt.Errorf("get default branch: %w", err) + } + + proj := projectPath(owner, repo) + payload := map[string]string{ + "branch": branchName, + "ref": defaultBranch, + } + resp, err := c.post(ctx, fmt.Sprintf("/projects/%s/repository/branches", proj), payload) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest && + strings.Contains(strings.ToLower(apiErr.Message), "already exists") { + return fmt.Errorf("create branch %s: %w: %w", branchName, forge.ErrAlreadyExists, err) + } + return fmt.Errorf("create branch %s: %w", branchName, err) + } + resp.Body.Close() + return nil +} + +// GetRef maps forge-style ref paths ("heads/main", "tags/v1") to GitLab +// commit lookups. GitLab's commits endpoint accepts branch names, tag +// names, and SHAs directly. +func (c *LiveClient) GetRef(ctx context.Context, owner, repo, refPath string) (string, error) { + ref := refPath + if after, ok := strings.CutPrefix(refPath, "heads/"); ok { + ref = after + } else if after, ok := strings.CutPrefix(refPath, "tags/"); ok { + ref = after + } + + proj := projectPath(owner, repo) + resp, err := c.get(ctx, fmt.Sprintf("/projects/%s/repository/commits/%s", proj, url.PathEscape(ref))) + if err != nil { + return "", fmt.Errorf("get ref %s/%s@%s: %w", owner, repo, refPath, err) + } + var commit struct { + ID string `json:"id"` + } + if err := decodeJSON(resp, &commit); err != nil { + return "", fmt.Errorf("decode commit: %w", err) + } + return commit.ID, nil +} + +func (c *LiveClient) CreateFile(ctx context.Context, owner, repo, path, message string, content []byte) error { + branch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return fmt.Errorf("get default branch: %w", err) + } + return c.CreateFileOnBranch(ctx, owner, repo, branch, path, message, content) +} + +func (c *LiveClient) CreateFileOnBranch(ctx context.Context, owner, repo, branch, path, message string, content []byte) error { + proj := projectPath(owner, repo) + apiPath := fmt.Sprintf("/projects/%s/repository/files/%s", proj, url.PathEscape(path)) + payload := map[string]string{ + "branch": branch, + "content": base64.StdEncoding.EncodeToString(content), + "encoding": "base64", + "commit_message": message, + } + resp, err := c.post(ctx, apiPath, payload) + if err != nil { + return fmt.Errorf("create file %s: %w", path, err) + } + resp.Body.Close() + return nil +} + +// CreateOrUpdateFile tries a POST (create); on 400 "already exists" it +// falls back to PUT (update). GitLab's file API does not require a SHA +// for updates, unlike GitHub. +func (c *LiveClient) CreateOrUpdateFile(ctx context.Context, owner, repo, path, message string, content []byte) error { + branch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return fmt.Errorf("get default branch: %w", err) + } + return c.CreateOrUpdateFileOnBranch(ctx, owner, repo, branch, path, message, content) +} + +func (c *LiveClient) CreateOrUpdateFileOnBranch(ctx context.Context, owner, repo, branch, path, message string, content []byte) error { + proj := projectPath(owner, repo) + apiPath := fmt.Sprintf("/projects/%s/repository/files/%s", proj, url.PathEscape(path)) + payload := map[string]string{ + "branch": branch, + "content": base64.StdEncoding.EncodeToString(content), + "encoding": "base64", + "commit_message": message, + } + + resp, err := c.do(ctx, http.MethodPost, apiPath, payload) + if err != nil { + return fmt.Errorf("create file %s: %w", path, err) + } + if err := checkStatus(resp, http.StatusCreated); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest && + strings.Contains(strings.ToLower(apiErr.Message), "already exists") { + updateResp, updateErr := c.put(ctx, apiPath, payload) + if updateErr != nil { + return fmt.Errorf("update file %s: %w", path, updateErr) + } + updateResp.Body.Close() + return nil + } + return fmt.Errorf("create file %s: %w", path, err) + } + resp.Body.Close() + return nil +} + +func (c *LiveClient) GetFileContent(ctx context.Context, owner, repo, path string) ([]byte, error) { + return c.GetFileContentAtRef(ctx, owner, repo, path, "HEAD") +} + +func (c *LiveClient) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) ([]byte, error) { + proj := projectPath(owner, repo) + apiPath := fmt.Sprintf("/projects/%s/repository/files/%s?ref=%s", + proj, url.PathEscape(path), url.QueryEscape(ref)) + resp, err := c.get(ctx, apiPath) + if err != nil { + return nil, fmt.Errorf("get file content: %w", err) + } + + var file struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := decodeJSON(resp, &file); err != nil { + return nil, fmt.Errorf("decode file content: %w", err) + } + + if file.Encoding != "base64" { + return []byte(file.Content), nil + } + data, err := base64.StdEncoding.DecodeString(file.Content) + if err != nil { + return nil, fmt.Errorf("decode base64 content: %w", err) + } + return data, nil +} + +func (c *LiveClient) DeleteFile(ctx context.Context, owner, repo, path, message string) error { + branch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return fmt.Errorf("get default branch: %w", err) + } + return c.deleteFileOnBranch(ctx, owner, repo, branch, path, message) +} + +func (c *LiveClient) deleteFileOnBranch(ctx context.Context, owner, repo, branch, path, message string) error { + proj := projectPath(owner, repo) + apiPath := fmt.Sprintf("/projects/%s/repository/files/%s", proj, url.PathEscape(path)) + payload := map[string]string{ + "branch": branch, + "commit_message": message, + } + resp, err := c.do(ctx, http.MethodDelete, apiPath, payload) + if err != nil { + return fmt.Errorf("delete file %s: %w", path, err) + } + defer resp.Body.Close() + return checkStatus(resp, http.StatusNoContent) +} + +func (c *LiveClient) DeleteFiles(ctx context.Context, owner, repo, message string, paths []string) (int, error) { + if len(paths) == 0 { + return 0, nil + } + + branch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return 0, fmt.Errorf("get default branch: %w", err) + } + + existing, err := c.getTreeMap(ctx, owner, repo, branch) + if err != nil { + return 0, fmt.Errorf("get tree for delete: %w", err) + } + + var actions []map[string]any + for _, p := range paths { + if _, ok := existing[p]; !ok { + continue + } + actions = append(actions, map[string]any{ + "action": "delete", + "file_path": p, + }) + } + + if len(actions) == 0 { + return 0, nil + } + + proj := projectPath(owner, repo) + payload := map[string]any{ + "branch": branch, + "commit_message": message, + "actions": actions, + } + resp, err := c.post(ctx, fmt.Sprintf("/projects/%s/repository/commits", proj), payload) + if err != nil { + return 0, fmt.Errorf("delete files commit: %w", err) + } + resp.Body.Close() + + return len(actions), nil +} + +func (c *LiveClient) ListDirectoryContents(ctx context.Context, owner, repo, path, ref string, recursive bool) ([]forge.DirectoryEntry, error) { + proj := projectPath(owner, repo) + + params := url.Values{} + if path != "" { + params.Set("path", path) + } + params.Set("ref", ref) + if recursive { + params.Set("recursive", "true") + } + params.Set("per_page", "100") + + var result []forge.DirectoryEntry + for page := 1; page <= maxTreePages; page++ { + params.Set("page", fmt.Sprintf("%d", page)) + apiPath := fmt.Sprintf("/projects/%s/repository/tree?%s", proj, params.Encode()) + + resp, err := c.get(ctx, apiPath) + if err != nil { + return nil, fmt.Errorf("list directory: %w", err) + } + + nextPage := resp.Header.Get("X-Next-Page") + + var entries []struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + } + if err := decodeJSON(resp, &entries); err != nil { + return nil, fmt.Errorf("decode directory listing: %w", err) + } + + for _, e := range entries { + if e.Type != "blob" && e.Type != "tree" { + continue + } + + relPath := e.Path + if path != "" { + relPath = strings.TrimPrefix(e.Path, path+"/") + } + + entryType := "file" + if e.Type == "tree" { + entryType = "dir" + } + + result = append(result, forge.DirectoryEntry{ + Path: relPath, + Type: entryType, + }) + } + + if nextPage == "" || len(entries) < 100 { + break + } + } + + return result, nil +} + +func (c *LiveClient) ListRepositoryFiles(ctx context.Context, owner, repo string) ([]string, error) { + proj := projectPath(owner, repo) + var paths []string + + for page := 1; page <= maxTreePages; page++ { + apiPath := fmt.Sprintf("/projects/%s/repository/tree?recursive=true&per_page=100&page=%d", proj, page) + resp, err := c.get(ctx, apiPath) + if err != nil { + return nil, fmt.Errorf("list repository files: %w", err) + } + + nextPage := resp.Header.Get("X-Next-Page") + + var entries []struct { + Path string `json:"path"` + Type string `json:"type"` + } + if err := decodeJSON(resp, &entries); err != nil { + return nil, fmt.Errorf("decode tree: %w", err) + } + + for _, e := range entries { + if e.Type == "blob" { + paths = append(paths, e.Path) + } + } + + if nextPage == "" || len(entries) < 100 { + break + } + } + + return paths, nil +} + +// CommitFiles atomically commits multiple files to the default branch +// via GitLab's Commits API. Returns (false, nil) when all files already +// match the current tree (idempotent). +func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message string, files []forge.TreeFile) (bool, error) { + if len(files) == 0 { + return false, nil + } + branch, err := c.getDefaultBranch(ctx, owner, repo) + if err != nil { + return false, fmt.Errorf("get default branch: %w", err) + } + return c.commitFilesImpl(ctx, owner, repo, branch, message, files) +} + +func (c *LiveClient) CommitFilesToBranch(ctx context.Context, owner, repo, branch, message string, files []forge.TreeFile) (bool, error) { + if len(files) == 0 { + return false, nil + } + return c.commitFilesImpl(ctx, owner, repo, branch, message, files) +} + +// commitFilesImpl reads the tree, computes a diff, and POSTs a commit. +// This is a non-atomic read-modify-write; concurrent branch updates may +// cause a 409 Conflict (mapped to ErrNonFastForward). The GitHub client +// shares this structural pattern. +func (c *LiveClient) commitFilesImpl(ctx context.Context, owner, repo, branch, message string, files []forge.TreeFile) (bool, error) { + existing, err := c.getTreeMap(ctx, owner, repo, branch) + if err != nil { + return false, fmt.Errorf("get tree: %w", err) + } + + var actions []map[string]any + for _, f := range files { + if f.Delete { + if _, ok := existing[f.Path]; !ok { + continue + } + actions = append(actions, map[string]any{ + "action": "delete", + "file_path": f.Path, + }) + continue + } + + expectedSHA := blobSHA(f.Content) + info, exists := existing[f.Path] + if exists && info.sha == expectedSHA && info.mode == f.Mode { + continue + } + + action := "create" + if exists { + action = "update" + } + + entry := map[string]any{ + "action": action, + "file_path": f.Path, + "content": base64.StdEncoding.EncodeToString(f.Content), + "encoding": "base64", + } + if f.Mode == "100755" { + entry["execute_filemode"] = true + } else if exists && info.mode == "100755" { + entry["execute_filemode"] = false + } + + actions = append(actions, entry) + } + + if len(actions) == 0 { + return false, nil + } + + proj := projectPath(owner, repo) + payload := map[string]any{ + "branch": branch, + "commit_message": message, + "actions": actions, + } + + resp, err := c.post(ctx, fmt.Sprintf("/projects/%s/repository/commits", proj), payload) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + msg := strings.ToLower(apiErr.Message) + if apiErr.StatusCode == http.StatusForbidden && + (strings.Contains(msg, "protected") || strings.Contains(msg, "not allowed to push")) { + return false, fmt.Errorf("%w: %w", forge.ErrBranchProtected, err) + } + if apiErr.StatusCode == http.StatusConflict && + !strings.Contains(msg, "already exists") { + return false, fmt.Errorf("%w: %w", forge.ErrNonFastForward, err) + } + } + return false, fmt.Errorf("create commit: %w", err) + } + resp.Body.Close() + + return true, nil +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 36b58a10bf..05839598be 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -38,7 +38,7 @@ func (p *Printer) Banner(version string) { brand := lipgloss.NewStyle().Bold(true).Foreground(ColorBrand).Render("fullsend") ver := lipgloss.NewStyle().Foreground(ColorMuted).Render(version) fmt.Fprintf(p.w, "\u26a1 %s %s\n", brand, ver) - tagline := lipgloss.NewStyle().Foreground(ColorMuted).Render("Autonomous agentic development for GitHub organizations") + tagline := lipgloss.NewStyle().Foreground(ColorMuted).Render("Autonomous agentic development for Git-hosted organizations") fmt.Fprintf(p.w, " %s\n", tagline) }