diff --git a/e2e/harness/gitea.go b/e2e/harness/gitea.go index 04c8a57..525a670 100644 --- a/e2e/harness/gitea.go +++ b/e2e/harness/gitea.go @@ -245,8 +245,16 @@ func (g *GiteaContainer) CreateRepo(ctx context.Context, name string) (*Repo, er // `src/app.ts` and `cdk/stack.ts` non-deterministically lost one trigger and // skipped the matching build/deploy job. func (g *GiteaContainer) CreateCommit(ctx context.Context, repo *Repo, message string, files map[string]string) (string, error) { + return g.CreateCommitOnBranch(ctx, repo, "main", message, files) +} + +// CreateCommitOnBranch creates a single commit containing all files on the +// given branch via Gitea's multi-file contents API. The contents API is atomic +// so all files land in one commit, preserving the orchestrator's change +// detection across multi-file edits. +func (g *GiteaContainer) CreateCommitOnBranch(ctx context.Context, repo *Repo, branch, message string, files map[string]string) (string, error) { if len(files) == 0 { - return g.getHeadSHA(ctx, repo) + return g.GetBranchSHA(ctx, repo, branch) } type fileOp struct { @@ -266,7 +274,7 @@ func (g *GiteaContainer) CreateCommit(ctx context.Context, repo *Repo, message s ops := make([]fileOp, 0, len(files)) for _, path := range paths { - op, sha, err := g.classifyFileOp(ctx, repo, path) + op, sha, err := g.classifyFileOp(ctx, repo, branch, path) if err != nil { return "", err } @@ -280,7 +288,7 @@ func (g *GiteaContainer) CreateCommit(ctx context.Context, repo *Repo, message s payload := map[string]interface{}{ "message": message, - "branch": "main", + "branch": branch, "files": ops, } body, err := json.Marshal(payload) @@ -319,14 +327,15 @@ func (g *GiteaContainer) CreateCommit(ctx context.Context, repo *Repo, message s if result.Commit.SHA != "" { return result.Commit.SHA, nil } - return g.getHeadSHA(ctx, repo) + return g.GetBranchSHA(ctx, repo, branch) } // classifyFileOp determines whether a file needs a "create" or "update" -// operation, and returns the existing SHA (required for updates). -func (g *GiteaContainer) classifyFileOp(ctx context.Context, repo *Repo, path string) (string, string, error) { +// operation on the given branch, and returns the existing SHA (required for +// updates). +func (g *GiteaContainer) classifyFileOp(ctx context.Context, repo *Repo, branch, path string) (string, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", - fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s?ref=main", g.url, AdminUsername, repo.Name, path), nil) + fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s?ref=%s", g.url, AdminUsername, repo.Name, path, branch), nil) if err != nil { return "", "", fmt.Errorf("build file-check request: %w", err) } @@ -349,10 +358,15 @@ func (g *GiteaContainer) classifyFileOp(ctx context.Context, repo *Repo, path st return "create", "", nil } -// getHeadSHA returns the current HEAD SHA of the main branch +// getHeadSHA returns the current HEAD SHA of the main branch. func (g *GiteaContainer) getHeadSHA(ctx context.Context, repo *Repo) (string, error) { + return g.GetBranchSHA(ctx, repo, "main") +} + +// GetBranchSHA returns the current HEAD commit SHA of the named branch. +func (g *GiteaContainer) GetBranchSHA(ctx context.Context, repo *Repo, branch string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", - fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/main", g.url, AdminUsername, repo.Name), nil) + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", g.url, AdminUsername, repo.Name, branch), nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } @@ -382,6 +396,101 @@ func (g *GiteaContainer) getHeadSHA(ctx context.Context, repo *Repo) (string, er return result.Commit.ID, nil } +// CreateBranch creates a new branch named name starting from fromSHA. Gitea's +// branches API takes a starting commit SHA via old_ref_name set to the SHA, so +// the new branch points at the requested commit. +func (g *GiteaContainer) CreateBranch(ctx context.Context, repo *Repo, name, fromSHA string) error { + payload := map[string]interface{}{ + "new_branch_name": name, + "old_ref_name": fromSHA, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal create-branch payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", g.url, AdminUsername, repo.Name), + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build create-branch request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("create-branch request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("create-branch failed: %s - %s", resp.Status, string(respBody)) + } + + return nil +} + +// DeleteBranch removes the named branch from the repository. +func (g *GiteaContainer) DeleteBranch(ctx context.Context, repo *Repo, name string) error { + req, err := http.NewRequestWithContext(ctx, "DELETE", + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", g.url, AdminUsername, repo.Name, name), nil) + if err != nil { + return fmt.Errorf("build delete-branch request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("delete-branch request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete-branch failed: %s - %s", resp.Status, string(respBody)) + } + + return nil +} + +// ListBranches returns the names of all branches in the repository. +func (g *GiteaContainer) ListBranches(ctx context.Context, repo *Repo) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", g.url, AdminUsername, repo.Name), nil) + if err != nil { + return nil, fmt.Errorf("build list-branches request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list-branches request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list-branches failed: %s - %s", resp.Status, string(respBody)) + } + + var results []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, fmt.Errorf("decode list-branches response: %w", err) + } + + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.Name + } + + return names, nil +} + // CreateTag creates a tag pointing to the given SHA func (g *GiteaContainer) CreateTag(ctx context.Context, repo *Repo, tag, sha string) error { payload := map[string]interface{}{ @@ -455,10 +564,15 @@ func (g *GiteaContainer) GetTags(ctx context.Context, repo *Repo) ([]string, err return tags, nil } -// GetFileContent retrieves file content from the repository +// GetFileContent retrieves file content from the main branch of the repository. func (g *GiteaContainer) GetFileContent(ctx context.Context, repo *Repo, filepath string) (string, error) { + return g.GetFileContentOnBranch(ctx, repo, filepath, "main") +} + +// GetFileContentOnBranch retrieves file content from the given branch. +func (g *GiteaContainer) GetFileContentOnBranch(ctx context.Context, repo *Repo, filepath, branch string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", - fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s?ref=main", g.url, AdminUsername, repo.Name, filepath), nil) + fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s?ref=%s", g.url, AdminUsername, repo.Name, filepath, branch), nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } @@ -560,3 +674,447 @@ func isRCTagForBase(tag, baseVersion string) bool { } return true } + +// GiteaRelease holds summary information about a Gitea release. +type GiteaRelease struct { + ID int64 + TagName string + Name string + IsDraft bool + IsPrerelease bool +} + +// CreatePR opens a pull request from head into base with the given title and +// body, applies any labels (creating each label on the repository first if it +// does not already exist), and returns the pull request index. +func (g *GiteaContainer) CreatePR(ctx context.Context, repo *Repo, head, base, title, body string, labels []string) (int64, error) { + payload := map[string]interface{}{ + "head": head, + "base": base, + "title": title, + "body": body, + } + + reqBody, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("marshal create-pr payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", g.url, AdminUsername, repo.Name), + bytes.NewReader(reqBody)) + if err != nil { + return 0, fmt.Errorf("build create-pr request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("create-pr request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("create-pr failed: %s - %s", resp.Status, string(respBody)) + } + + var result struct { + Number int64 `json:"number"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decode create-pr response: %w", err) + } + + if len(labels) > 0 { + labelIDs, err := g.ensureLabels(ctx, repo, labels) + if err != nil { + return 0, err + } + if err := g.applyLabels(ctx, repo, result.Number, labelIDs); err != nil { + return 0, err + } + } + + return result.Number, nil +} + +// ensureLabels returns the label IDs for the given label names, creating any +// label that does not already exist on the repository. +func (g *GiteaContainer) ensureLabels(ctx context.Context, repo *Repo, names []string) ([]int64, error) { + existing, err := g.listLabels(ctx, repo) + if err != nil { + return nil, err + } + + ids := make([]int64, 0, len(names)) + for _, name := range names { + if id, ok := existing[name]; ok { + ids = append(ids, id) + continue + } + id, err := g.createLabel(ctx, repo, name) + if err != nil { + return nil, err + } + existing[name] = id + ids = append(ids, id) + } + + return ids, nil +} + +// listLabels returns a map of label name to label ID for the repository. +func (g *GiteaContainer) listLabels(ctx context.Context, repo *Repo) (map[string]int64, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", g.url, AdminUsername, repo.Name), nil) + if err != nil { + return nil, fmt.Errorf("build list-labels request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list-labels request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list-labels failed: %s - %s", resp.Status, string(respBody)) + } + + var results []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, fmt.Errorf("decode list-labels response: %w", err) + } + + out := make(map[string]int64, len(results)) + for _, r := range results { + out[r.Name] = r.ID + } + + return out, nil +} + +// createLabel creates a single label on the repository and returns its ID. +func (g *GiteaContainer) createLabel(ctx context.Context, repo *Repo, name string) (int64, error) { + payload := map[string]interface{}{ + "name": name, + "color": "#00aabb", + } + body, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("marshal create-label payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", g.url, AdminUsername, repo.Name), + bytes.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("build create-label request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("create-label request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("create-label failed: %s - %s", resp.Status, string(respBody)) + } + + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decode create-label response: %w", err) + } + + return result.ID, nil +} + +// applyLabels attaches the given label IDs to the issue/PR with the given index. +func (g *GiteaContainer) applyLabels(ctx context.Context, repo *Repo, index int64, labelIDs []int64) error { + payload := map[string]interface{}{ + "labels": labelIDs, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal apply-labels payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", g.url, AdminUsername, repo.Name, index), + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build apply-labels request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("apply-labels request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("apply-labels failed: %s - %s", resp.Status, string(respBody)) + } + + return nil +} + +// MergePR merges the pull request with the given index using the named merge +// style ("squash", "merge", or "rebase"). Gitea computes a pull request's +// mergeability asynchronously and returns 405 "Please try again later" if the +// merge is attempted before that check completes, so this waits for the PR to +// report mergeable before issuing the merge. +func (g *GiteaContainer) MergePR(ctx context.Context, repo *Repo, index int64, style string) error { + if err := g.waitForMergeable(ctx, repo, index, 30*time.Second); err != nil { + return err + } + + payload := map[string]interface{}{ + "Do": style, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal merge-pr payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", g.url, AdminUsername, repo.Name, index), + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build merge-pr request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("merge-pr request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Gitea returns 200 OK on a successful merge. + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("merge-pr failed: %s - %s", resp.Status, string(respBody)) + } + + return nil +} + +// waitForMergeable polls the pull request until Gitea reports it mergeable or +// the timeout elapses. +func (g *GiteaContainer) waitForMergeable(ctx context.Context, repo *Repo, index int64, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + mergeable, err := g.prMergeable(ctx, repo, index) + if err != nil { + return err + } + if mergeable { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("pull request %d not mergeable after %s", index, timeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +// prMergeable returns whether Gitea currently considers the pull request +// mergeable. +func (g *GiteaContainer) prMergeable(ctx context.Context, repo *Repo, index int64) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", g.url, AdminUsername, repo.Name, index), nil) + if err != nil { + return false, fmt.Errorf("build get-pr request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("get-pr request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("get-pr failed: %s - %s", resp.Status, string(respBody)) + } + + var result struct { + Mergeable bool `json:"mergeable"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, fmt.Errorf("decode get-pr response: %w", err) + } + + return result.Mergeable, nil +} + +// ListOpenPRs returns the indices of open pull requests targeting base. When +// label is non-empty, only pull requests carrying that label are returned. +func (g *GiteaContainer) ListOpenPRs(ctx context.Context, repo *Repo, base, label string) ([]int64, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=open&base=%s&limit=50", g.url, AdminUsername, repo.Name, base), nil) + if err != nil { + return nil, fmt.Errorf("build list-prs request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list-prs request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list-prs failed: %s - %s", resp.Status, string(respBody)) + } + + var results []struct { + Number int64 `json:"number"` + Base struct { + Ref string `json:"ref"` + } `json:"base"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + } + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, fmt.Errorf("decode list-prs response: %w", err) + } + + indices := make([]int64, 0, len(results)) + for _, pr := range results { + // Gitea filters by base server-side, but guard in case of API drift. + if base != "" && pr.Base.Ref != base { + continue + } + if label != "" { + matched := false + for _, l := range pr.Labels { + if l.Name == label { + matched = true + break + } + } + if !matched { + continue + } + } + indices = append(indices, pr.Number) + } + + return indices, nil +} + +// CreateRelease creates a release in the repository and returns its ID. +func (g *GiteaContainer) CreateRelease(ctx context.Context, repo *Repo, tagName, name, body string, isDraft, isPrerelease bool) (int64, error) { + payload := map[string]interface{}{ + "tag_name": tagName, + "name": name, + "body": body, + "draft": isDraft, + "prerelease": isPrerelease, + } + + reqBody, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("marshal create-release payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", + fmt.Sprintf("%s/api/v1/repos/%s/%s/releases", g.url, AdminUsername, repo.Name), + bytes.NewReader(reqBody)) + if err != nil { + return 0, fmt.Errorf("build create-release request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("create-release request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("create-release failed: %s - %s", resp.Status, string(respBody)) + } + + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decode create-release response: %w", err) + } + + return result.ID, nil +} + +// GetReleases returns summary information for every release in the repository. +func (g *GiteaContainer) GetReleases(ctx context.Context, repo *Repo) ([]GiteaRelease, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + fmt.Sprintf("%s/api/v1/repos/%s/%s/releases", g.url, AdminUsername, repo.Name), nil) + if err != nil { + return nil, fmt.Errorf("build get-releases request: %w", err) + } + req.SetBasicAuth(AdminUsername, AdminPassword) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get-releases request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get-releases failed: %s - %s", resp.Status, string(respBody)) + } + + var results []struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + } + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, fmt.Errorf("decode get-releases response: %w", err) + } + + releases := make([]GiteaRelease, len(results)) + for i, r := range results { + releases[i] = GiteaRelease{ + ID: r.ID, + TagName: r.TagName, + Name: r.Name, + IsDraft: r.Draft, + IsPrerelease: r.Prerelease, + } + } + + return releases, nil +} diff --git a/e2e/harness/gitea_primitives_test.go b/e2e/harness/gitea_primitives_test.go new file mode 100644 index 0000000..3c6c879 --- /dev/null +++ b/e2e/harness/gitea_primitives_test.go @@ -0,0 +1,182 @@ +package harness + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGiteaHotfixPrimitives exercises the branch, commit, pull request, and +// release primitives that the hotfix scenarios depend on. Each subtest creates +// its own repository against a single shared Gitea container so the (slow) +// container start cost is paid once. +func TestGiteaHotfixPrimitives(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + + gitea, err := NewGiteaContainer(ctx, "", nil) + require.NoError(t, err) + defer func() { _ = gitea.Terminate(ctx) }() + + // seedRepo creates a repo with one commit on main and returns it plus the + // main HEAD SHA. + seedRepo := func(t *testing.T, name string) (*Repo, string) { + t.Helper() + repo, err := gitea.CreateRepo(ctx, name) + require.NoError(t, err) + sha, err := gitea.CreateCommit(ctx, repo, "feat: seed", map[string]string{ + "README.md": "# " + name + "\n", + }) + require.NoError(t, err) + require.Len(t, sha, 40) + return repo, sha + } + + t.Run("CreateAndGetBranch", func(t *testing.T) { + repo, headSHA := seedRepo(t, "branch-get") + + require.NoError(t, gitea.CreateBranch(ctx, repo, "hotfix/x", headSHA)) + + got, err := gitea.GetBranchSHA(ctx, repo, "hotfix/x") + require.NoError(t, err) + assert.Len(t, got, 40) + assert.Equal(t, headSHA, got) + }) + + t.Run("ListBranches", func(t *testing.T) { + repo, headSHA := seedRepo(t, "branch-list") + + require.NoError(t, gitea.CreateBranch(ctx, repo, "release/1.0", headSHA)) + + // Gitea's branch listing is eventually consistent: a freshly created + // branch is immediately resolvable by name but can take a moment to + // appear in the list endpoint. + require.Eventually(t, func() bool { + branches, err := gitea.ListBranches(ctx, repo) + if err != nil { + return false + } + return contains(branches, "main") && contains(branches, "release/1.0") + }, 10*time.Second, 250*time.Millisecond) + }) + + t.Run("CreateCommitOnBranch", func(t *testing.T) { + repo, headSHA := seedRepo(t, "commit-branch") + + require.NoError(t, gitea.CreateBranch(ctx, repo, "feature/y", headSHA)) + + newSHA, err := gitea.CreateCommitOnBranch(ctx, repo, "feature/y", "feat: work", map[string]string{ + "src/app.go": "package app\n", + }) + require.NoError(t, err) + assert.Len(t, newSHA, 40) + assert.NotEqual(t, headSHA, newSHA) + + branchSHA, err := gitea.GetBranchSHA(ctx, repo, "feature/y") + require.NoError(t, err) + assert.Equal(t, newSHA, branchSHA) + + // main must be untouched. + mainSHA, err := gitea.GetBranchSHA(ctx, repo, "main") + require.NoError(t, err) + assert.Equal(t, headSHA, mainSHA) + + // File is reachable on the feature branch. + content, err := gitea.GetFileContentOnBranch(ctx, repo, "src/app.go", "feature/y") + require.NoError(t, err) + assert.Equal(t, "package app\n", content) + }) + + t.Run("CreateAndMergePR", func(t *testing.T) { + repo, headSHA := seedRepo(t, "pr-merge") + + require.NoError(t, gitea.CreateBranch(ctx, repo, "hotfix/fix", headSHA)) + _, err := gitea.CreateCommitOnBranch(ctx, repo, "hotfix/fix", "fix: patch", map[string]string{ + "patch.txt": "patched\n", + }) + require.NoError(t, err) + + index, err := gitea.CreatePR(ctx, repo, "hotfix/fix", "main", "Hotfix", "body", []string{"hotfix"}) + require.NoError(t, err) + assert.Positive(t, index) + + open, err := gitea.ListOpenPRs(ctx, repo, "main", "hotfix") + require.NoError(t, err) + assert.Contains(t, open, index) + + // A non-matching label must filter the PR out. + none, err := gitea.ListOpenPRs(ctx, repo, "main", "nonexistent") + require.NoError(t, err) + assert.NotContains(t, none, index) + + require.NoError(t, gitea.MergePR(ctx, repo, index, "squash")) + + // After merge the source branch can be deleted. + require.NoError(t, gitea.DeleteBranch(ctx, repo, "hotfix/fix")) + }) + + t.Run("DeleteBranch", func(t *testing.T) { + repo, headSHA := seedRepo(t, "branch-delete") + + require.NoError(t, gitea.CreateBranch(ctx, repo, "throwaway", headSHA)) + + // Wait for the new branch to surface in the (eventually consistent) + // list endpoint before deleting it. + require.Eventually(t, func() bool { + branches, err := gitea.ListBranches(ctx, repo) + return err == nil && contains(branches, "throwaway") + }, 10*time.Second, 250*time.Millisecond) + + require.NoError(t, gitea.DeleteBranch(ctx, repo, "throwaway")) + + require.Eventually(t, func() bool { + branches, err := gitea.ListBranches(ctx, repo) + return err == nil && !contains(branches, "throwaway") + }, 10*time.Second, 250*time.Millisecond) + }) + + t.Run("CreateAndGetRelease", func(t *testing.T) { + repo, headSHA := seedRepo(t, "release-get") + + // A release needs a tag to point at. + require.NoError(t, gitea.CreateTag(ctx, repo, "v1.0.0", headSHA)) + + id, err := gitea.CreateRelease(ctx, repo, "v1.0.0", "Release 1.0.0", "notes", true, false) + require.NoError(t, err) + assert.Positive(t, id) + + releases, err := gitea.GetReleases(ctx, repo) + require.NoError(t, err) + + var found *GiteaRelease + for i := range releases { + if releases[i].ID == id { + found = &releases[i] + break + } + } + require.NotNil(t, found, fmt.Sprintf("release %d not present in %+v", id, releases)) + assert.Equal(t, "v1.0.0", found.TagName) + assert.Equal(t, "Release 1.0.0", found.Name) + assert.True(t, found.IsDraft) + assert.False(t, found.IsPrerelease) + }) +} + +// contains reports whether s appears in xs. +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +}