diff --git a/gitea/client.go b/gitea/client.go index 335da97..0662c1e 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -18,8 +18,9 @@ import ( // Client wraps the Gitea SDK client with config-based auth. type Client struct { - api *gitea.Client - url string + api *gitea.Client + url string + token string } // New creates a new Gitea API client for the given URL and token. @@ -30,7 +31,7 @@ func New(url, token string) (*Client, error) { return nil, log.E("gitea.New", "failed to create client", err) } - return &Client{api: api, url: url}, nil + return &Client{api: api, url: url, token: token}, nil } // API exposes the underlying SDK client for direct access. @@ -40,3 +41,7 @@ func (c *Client) API() *gitea.Client { return c.api } // URL returns the Gitea instance URL. // Usage: URL(...) func (c *Client) URL() string { return c.url } + +// Token returns the Gitea API token. +// Usage: Token(...) +func (c *Client) Token() string { return c.token } diff --git a/gitea/prs.go b/gitea/prs.go new file mode 100644 index 0000000..300559b --- /dev/null +++ b/gitea/prs.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package gitea + +import ( + "bytes" + "iter" + "net/http" + "net/url" + "strconv" + + "code.gitea.io/sdk/gitea" + + "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + "dappco.re/go/core/scm/internal/ax/jsonx" +) + +// MergePullRequest merges a pull request with the given method ("squash", "rebase", "merge"). +// Usage: MergePullRequest(...) +func (c *Client) MergePullRequest(owner, repo string, index int64, method string) error { + style := gitea.MergeStyleMerge + switch method { + case "squash": + style = gitea.MergeStyleSquash + case "rebase": + style = gitea.MergeStyleRebase + case "rebase-merge": + style = gitea.MergeStyleRebaseMerge + } + + merged, _, err := c.api.MergePullRequest(owner, repo, index, gitea.MergePullRequestOption{ + Style: style, + DeleteBranchAfterMerge: true, + }) + if err != nil { + return log.E("gitea.MergePullRequest", "failed to merge pull request", err) + } + if !merged { + return log.E("gitea.MergePullRequest", "failed to merge pull request", nil) + } + return nil +} + +// SetPRDraft sets or clears the draft status on a pull request. +// The Gitea SDK exposes draft state on the model, but the edit option does not +// currently include a draft field, so we use a raw PATCH request. +// Usage: SetPRDraft(...) +func (c *Client) SetPRDraft(owner, repo string, index int64, draft bool) error { + safeOwner, err := agentci.ValidatePathElement(owner) + if err != nil { + return log.E("gitea.SetPRDraft", "invalid owner", err) + } + safeRepo, err := agentci.ValidatePathElement(repo) + if err != nil { + return log.E("gitea.SetPRDraft", "invalid repo", err) + } + + payload := map[string]bool{"draft": draft} + body, err := jsonx.Marshal(payload) + if err != nil { + return log.E("gitea.SetPRDraft", "marshal payload", err) + } + + path, err := url.JoinPath(c.url, "api", "v1", "repos", safeOwner, safeRepo, "pulls", strconv.FormatInt(index, 10)) + if err != nil { + return log.E("gitea.SetPRDraft", "failed to build request path", err) + } + + req, err := http.NewRequest(http.MethodPatch, path, bytes.NewReader(body)) + if err != nil { + return log.E("gitea.SetPRDraft", "create request", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "token "+c.Token()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return log.E("gitea.SetPRDraft", "failed to update draft status", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return log.E("gitea.SetPRDraft", "unexpected status "+strconv.Itoa(resp.StatusCode), nil) + } + return nil +} + +// ListPRReviews returns all reviews for a pull request. +// Usage: ListPRReviews(...) +func (c *Client) ListPRReviews(owner, repo string, index int64) ([]*gitea.PullReview, error) { + var all []*gitea.PullReview + page := 1 + + for { + reviews, resp, err := c.api.ListPullReviews(owner, repo, index, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("gitea.ListPRReviews", "failed to list reviews", err) + } + + all = append(all, reviews...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// ListPRReviewsIter returns an iterator over reviews for a pull request. +// Usage: ListPRReviewsIter(...) +func (c *Client) ListPRReviewsIter(owner, repo string, index int64) iter.Seq2[*gitea.PullReview, error] { + return func(yield func(*gitea.PullReview, error) bool) { + page := 1 + for { + reviews, resp, err := c.api.ListPullReviews(owner, repo, index, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + yield(nil, log.E("gitea.ListPRReviews", "failed to list reviews", err)) + return + } + for _, review := range reviews { + if !yield(review, nil) { + return + } + } + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + } +} + +// GetCombinedStatus returns the combined commit status for a ref (SHA or branch). +// Usage: GetCombinedStatus(...) +func (c *Client) GetCombinedStatus(owner, repo string, ref string) (*gitea.CombinedStatus, error) { + status, _, err := c.api.GetCombinedStatus(owner, repo, ref) + if err != nil { + return nil, log.E("gitea.GetCombinedStatus", "failed to get combined status", err) + } + return status, nil +} + +// DismissReview dismisses a pull request review by ID. +// Usage: DismissReview(...) +func (c *Client) DismissReview(owner, repo string, index, reviewID int64, message string) error { + _, err := c.api.DismissPullReview(owner, repo, index, reviewID, gitea.DismissPullReviewOptions{ + Message: message, + }) + if err != nil { + return log.E("gitea.DismissReview", "failed to dismiss review", err) + } + return nil +} + +// UndismissReview removes a dismissal from a pull request review. +// Usage: UndismissReview(...) +func (c *Client) UndismissReview(owner, repo string, index, reviewID int64) error { + _, err := c.api.UnDismissPullReview(owner, repo, index, reviewID) + if err != nil { + return log.E("gitea.UndismissReview", "failed to undismiss review", err) + } + return nil +} diff --git a/gitea/prs_test.go b/gitea/prs_test.go new file mode 100644 index 0000000..8f84c5d --- /dev/null +++ b/gitea/prs_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package gitea + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + giteaSDK "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_MergePullRequest_Good(t *testing.T) { + var method, path string + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/merge", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + err = client.MergePullRequest("test-org", "org-repo", 1, "merge") + require.NoError(t, err) + assert.Equal(t, http.MethodPost, method) + assert.Equal(t, "/api/v1/repos/test-org/org-repo/pulls/1/merge", path) +} + +func TestClient_MergePullRequest_Bad_ServerError_Good(t *testing.T) { + client, srv := newErrorServer(t) + defer srv.Close() + + err := client.MergePullRequest("test-org", "org-repo", 1, "merge") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to merge pull request") +} + +func TestClient_ListPRReviews_Good(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, []map[string]any{ + {"id": 1, "state": "APPROVED", "user": map[string]any{"login": "reviewer1"}}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + reviews, err := client.ListPRReviews("test-org", "org-repo", 1) + require.NoError(t, err) + require.Len(t, reviews, 1) + assert.Equal(t, giteaSDK.ReviewStateApproved, reviews[0].State) +} + +func TestClient_ListPRReviewsIter_Good_Paginates_Good(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "2": + jsonResponse(w, []map[string]any{ + {"id": 2, "state": "REQUEST_CHANGES", "user": map[string]any{"login": "reviewer2"}}, + }) + default: + w.Header().Set("Link", "; rel=\"next\", ; rel=\"last\"") + jsonResponse(w, []map[string]any{ + {"id": 1, "state": "APPROVED", "user": map[string]any{"login": "reviewer1"}}, + }) + } + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + var states []string + for review, err := range client.ListPRReviewsIter("test-org", "org-repo", 1) { + require.NoError(t, err) + states = append(states, string(review.State)) + } + + assert.Equal(t, []string{"APPROVED", "REQUEST_CHANGES"}, states) +} + +func TestClient_ListPRReviews_Bad_ServerError_Good(t *testing.T) { + client, srv := newErrorServer(t) + defer srv.Close() + + _, err := client.ListPRReviews("test-org", "org-repo", 1) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list reviews") +} + +func TestClient_GetCombinedStatus_Good(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/commits/main/status", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]any{ + "state": "success", + "sha": "abc123", + "total_count": 1, + "statuses": []map[string]any{ + {"state": "success", "context": "ci/test"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + status, err := client.GetCombinedStatus("test-org", "org-repo", "main") + require.NoError(t, err) + assert.Equal(t, giteaSDK.StatusSuccess, status.State) + assert.Equal(t, "abc123", status.SHA) +} + +func TestClient_GetCombinedStatus_Bad_ServerError_Good(t *testing.T) { + client, srv := newErrorServer(t) + defer srv.Close() + + _, err := client.GetCombinedStatus("test-org", "org-repo", "main") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get combined status") +} + +func TestClient_DismissReview_Good(t *testing.T) { + var method, path string + var payload map[string]any + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/reviews/1/dismissals", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + err = client.DismissReview("test-org", "org-repo", 1, 1, "outdated review") + require.NoError(t, err) + assert.Equal(t, http.MethodPost, method) + assert.Equal(t, "/api/v1/repos/test-org/org-repo/pulls/1/reviews/1/dismissals", path) + assert.Equal(t, "outdated review", payload["message"]) +} + +func TestClient_DismissReview_Bad_ServerError_Good(t *testing.T) { + client, srv := newErrorServer(t) + defer srv.Close() + + err := client.DismissReview("test-org", "org-repo", 1, 1, "outdated") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to dismiss review") +} + +func TestClient_SetPRDraft_Good_Request_Good(t *testing.T) { + var method, path string + var payload map[string]any + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/3", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + jsonResponse(w, map[string]any{"number": 3}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + err = client.SetPRDraft("test-org", "org-repo", 3, false) + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, method) + assert.Equal(t, "/api/v1/repos/test-org/org-repo/pulls/3", path) + assert.Equal(t, false, payload["draft"]) +} + +func TestClient_SetPRDraft_Bad_PathTraversalOwner_Good(t *testing.T) { + client, srv := newTestClient(t) + defer srv.Close() + + err := client.SetPRDraft("../owner", "org-repo", 3, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid owner") +} + +func TestClient_SetPRDraft_Bad_PathTraversalRepo_Good(t *testing.T) { + client, srv := newTestClient(t) + defer srv.Close() + + err := client.SetPRDraft("test-org", "..", 3, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo") +}