diff --git a/internal/services/action_logs.go b/internal/services/action_logs.go new file mode 100644 index 00000000..48f86041 --- /dev/null +++ b/internal/services/action_logs.go @@ -0,0 +1,168 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Job identifies one workflow job without exposing server-specific metadata. +type Job struct { + RunAttempt int64 `json:"run_attempt"` + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +// Jobs lists every page of jobs in the selected run. +func (s Actions) Jobs(ctx context.Context, run int64) ([]Job, error) { + return listAll(func(page int) ([]Job, error) { return s.jobsPage(ctx, run, 100, page) }) +} + +// JobsPage lists a page of jobs in the selected run. +func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) { + return s.jobsPage(ctx, run, limit, page) +} + +// WaitForFirstJob polls a freshly dispatched run until the server has created its first job. +func (s Actions) WaitForFirstJob(ctx context.Context, run int64, interval time.Duration) (Job, error) { + if interval <= 0 { + return Job{}, errors.New("job polling interval must be positive") + } + for { + jobs, err := s.Jobs(ctx, run) + if err != nil { + return Job{}, err + } + if len(jobs) > 0 { + return jobs[0], nil + } + if err := sleepCtx(ctx, interval); err != nil { + return Job{}, err + } + } +} + +func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) { + response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) { + return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(response.Jobs, jobFrom), nil +} + +func jobFrom(job *gitea.ActionsWorkflowJob) Job { + return Job{RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion} +} + +// JobLogs streams a job's plain-text logs within the selected instance. +func (s Actions) JobLogs(ctx context.Context, id int64) (io.ReadCloser, error) { + // TODO(sdk): GetRepoRunJobLogs buffers the whole log; a reader variant would allow streaming. + response, err := s.Client.Do(ctx, http.MethodGet, fmt.Sprintf("%sjobs/%d/logs", s.endpoint(), id), nil, nil) + if err != nil { + return nil, err + } + return response.Body, nil +} + +// FollowJobLogs emits only newly appended bytes and stops when the job completes. +// A shorter snapshot fails rather than silently mixing logs from different attempts. +func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time.Duration, emit func([]byte) error) error { + if interval <= 0 { + return fmt.Errorf("log polling interval must be positive") + } + var offset int64 + var attempt int64 + var previousHash []byte + for { + jobs, err := s.Jobs(ctx, run) + if err != nil { + return err + } + var current *Job + for i := range jobs { + if jobs[i].ID == id { + current = &jobs[i] + break + } + } + if current == nil { + return fmt.Errorf("job %d no longer belongs to run %d", id, run) + } + if attempt != 0 && current.RunAttempt != attempt { + return fmt.Errorf("job attempt changed while following logs; restart with the current attempt") + } + attempt = current.RunAttempt + logs, err := s.JobLogs(ctx, id) + if err != nil { + if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && offset == 0 && (current.Status == "queued" || current.Status == "waiting" || current.Status == "pending") { + if err := sleepCtx(ctx, interval); err != nil { + return err + } + continue + } + return err + } + err = func() error { + defer logs.Close() + snapshotHash := sha256.New() + if _, err := io.CopyN(snapshotHash, logs, offset); err != nil { + return fmt.Errorf("job log snapshot became shorter; select the current run attempt: %w", err) + } + if offset > 0 && !bytes.Equal(previousHash, snapshotHash.Sum(nil)) { + return errors.New("job log contents changed while following; restart with the current attempt") + } + buffer := make([]byte, 32*1024) + for { + n, err := logs.Read(buffer) + if n > 0 { + if emitErr := emit(buffer[:n]); emitErr != nil { + return emitErr + } + _, _ = snapshotHash.Write(buffer[:n]) + offset += int64(n) + } + if err == io.EOF { + previousHash = snapshotHash.Sum(nil) + return nil + } + if err != nil { + return err + } + } + }() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + if completedActionStatus(current.Status) { + return nil + } + if err := sleepCtx(ctx, interval); err != nil { + return err + } + } +} + +// RunLogsArchive streams the server's ZIP archive without extracting its entries. +func (s Actions) RunLogsArchive(ctx context.Context, run int64) (io.ReadCloser, error) { + return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) { + return c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run) + }) +} diff --git a/internal/services/action_logs_test.go b/internal/services/action_logs_test.go new file mode 100644 index 00000000..90da8027 --- /dev/null +++ b/internal/services/action_logs_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestFollowLogsEmitsOnlyAppendedBytesAndStops(t *testing.T) { + snapshots := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/jobs"): + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`{"jobs":[]}`)) + return + } + status := "in_progress" + if snapshots >= 2 { + status = "completed" + } + _, _ = fmt.Fprintf(w, `{"jobs":[{"id":9,"status":%q}]}`, status) + case strings.HasSuffix(r.URL.Path, "/logs"): + snapshots++ + if snapshots == 1 { + _, _ = w.Write([]byte("first\n")) + } else { + _, _ = w.Write([]byte("first\nsecond\n")) + } + default: + t.Errorf("unexpected endpoint %s", r.URL.Path) + w.WriteHeader(404) + } + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + var output strings.Builder + err := service.FollowJobLogs(context.Background(), 7, 9, time.Millisecond, func(chunk []byte) error { _, err := output.Write(chunk); return err }) + require.NoError(t, err) + require.Equal(t, "first\nsecond\n", output.String()) + require.Equal(t, 3, snapshots) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = service.FollowJobLogs(ctx, 7, 9, time.Millisecond, func([]byte) error { t.Fatal("canceled follow emitted logs"); return nil }) + require.ErrorIs(t, err, context.Canceled) +} + +func TestFollowLogsWaitsForQueuedJobAndRejectsChangedSnapshots(t *testing.T) { + for _, changed := range []bool{false, true} { + t.Run(fmt.Sprint(changed), func(t *testing.T) { + reads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/jobs"): + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`{"jobs":[]}`)) + return + } + status := "queued" + if reads > 0 { + status = "in_progress" + } + if reads > 1 && !changed { + status = "completed" + } + _, _ = fmt.Fprintf(w, `{"jobs":[{"id":9,"run_attempt":1,"status":%q}]}`, status) + case strings.HasSuffix(r.URL.Path, "/logs"): + reads++ + if reads == 1 { + w.WriteHeader(http.StatusNotFound) + return + } + if reads > 2 && changed { + _, _ = w.Write([]byte("other\nnew\n")) + return + } + _, _ = w.Write([]byte("first\n")) + } + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + var output strings.Builder + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err := service.FollowJobLogs(ctx, 7, 9, time.Millisecond, func(chunk []byte) error { _, err := output.Write(chunk); return err }) + if changed { + require.ErrorContains(t, err, "contents changed") + } else { + require.NoError(t, err) + } + require.Equal(t, "first\n", output.String()) + require.Equal(t, 3, reads) + }) + } +} + +func TestJobsPageMapsJobsAndSendsPagination(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path) + require.Equal(t, "20", r.URL.Query().Get("limit")) + require.Equal(t, "3", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`{"total_count":1,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded"}]}`)) + })) + jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3) + require.NoError(t, err) + require.Equal(t, []Job{{RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success"}}, jobs) +} + +func TestRunLogsArchiveStreams(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/logs", r.URL.Path) + _, _ = w.Write([]byte("PK archive")) + })) + body, err := (Actions{Client: client, Repository: "owner/repo"}).RunLogsArchive(context.Background(), 7) + require.NoError(t, err) + data, err := io.ReadAll(body) + require.NoError(t, err) + require.NoError(t, body.Close()) + require.Equal(t, "PK archive", string(data)) +} + +func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) { + polls := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/7/jobs": + polls++ + if polls < 3 || r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`{"jobs":[]}`)) + return + } + _, _ = w.Write([]byte(`{"jobs":[{"id":11,"name":"build"},{"id":12,"name":"test"}]}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + service := Actions{Client: client, Repository: "owner/repo"} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond) + require.NoError(t, err) + require.Equal(t, Job{ID: 11, Name: "build"}, job) + _, err = service.WaitForFirstJob(ctx, 7, 0) + require.ErrorContains(t, err, "interval must be positive") +} diff --git a/internal/services/action_values.go b/internal/services/action_values.go new file mode 100644 index 00000000..7c67a29a --- /dev/null +++ b/internal/services/action_values.go @@ -0,0 +1,221 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// ActionScope identifies exactly one repository, organization, or account scope. +type ActionScope struct { + Repository, Organization string + Account bool +} + +// ActionSecret describes a secret without containing its value. +type ActionSecret struct { + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +func secretFrom(secret *gitea.Secret) ActionSecret { + return ActionSecret{Name: secret.Name, Description: secret.Description, CreatedAt: timestamp(secret.Created)} +} + +// ActionVariable contains one non-secret workflow variable. +type ActionVariable struct { + Name string `json:"name"` + Value string `json:"value"` + Description string `json:"description"` +} + +func variableFrom(variable *gitea.ActionsVariable) ActionVariable { + return ActionVariable{Name: variable.Name, Value: variable.Data, Description: variable.Description} +} + +// ActionSecrets manages scoped Actions secrets without returning secret data. +type ActionSecrets struct { + Client *transport.Client + Scope ActionScope +} + +// List returns secret metadata only. +func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) { + if s.Scope.Account { + return nil, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"} + } + options := listOptions(limit, page) + secrets, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Secret, *gitea.Response, error) { + if s.Scope.Organization != "" { + return c.Actions.ListOrgSecrets(ctx, s.Scope.Organization, gitea.ListOrgActionsSecretOption{ListOptions: options}) + } + owner, repo, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, nil, err + } + return c.Actions.ListRepoSecrets(ctx, owner, repo, gitea.ListRepoActionsSecretOption{ListOptions: options}) + }) + if err != nil { + return nil, err + } + return convertAll(secrets, secretFrom), nil +} + +// Set creates or replaces a secret; the API's PUT operation defines upsert semantics. +func (s ActionSecrets) Set(ctx context.Context, name, value, description string) (ActionSecret, error) { + option := gitea.CreateOrUpdateSecretOption{Data: value, Description: description} + err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + switch { + case s.Scope.Account: + return c.Actions.CreateUserSecret(ctx, name, option) + case s.Scope.Organization != "": + return c.Actions.CreateOrgSecret(ctx, s.Scope.Organization, name, option) + } + owner, repo, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, err + } + return c.Actions.CreateRepoSecret(ctx, owner, repo, name, option) + }) + return ActionSecret{Name: name, Description: description}, err +} + +// Delete removes only the named secret in the selected scope. +func (s ActionSecrets) Delete(ctx context.Context, name string) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + switch { + case s.Scope.Account: + return c.Actions.DeleteUserSecret(ctx, name) + case s.Scope.Organization != "": + return c.Actions.DeleteOrgSecret(ctx, s.Scope.Organization, name) + } + owner, repo, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, err + } + // TODO(sdk): DeleteRepoSecret does not escape the secret name. + return c.Actions.DeleteRepoSecret(ctx, owner, repo, url.PathEscape(name)) + }) +} + +// ActionVariables manages scoped non-secret Actions variables. +type ActionVariables struct { + Client *transport.Client + Scope ActionScope +} + +// repoVariables addresses repository variables, or one of them when name is set, for raw requests. +// TODO(sdk): RepoActionsVariable.Description, a description on Create/UpdateRepoVariable, and name +// escaping in the repository variable methods. +func (s ActionVariables) repoVariables(name string) string { + endpoint := "repos/" + s.Scope.Repository + "/actions/variables" + if name != "" { + endpoint += "/" + url.PathEscape(name) + } + return endpoint +} + +// List returns a non-nil page of variables. +func (s ActionVariables) List(ctx context.Context, limit, page int) ([]ActionVariable, error) { + var variables []*gitea.ActionsVariable + var err error + switch { + case s.Scope.Account: + variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) { + return c.Actions.ListUserVariables(ctx, listOptions(limit, page)) + }) + case s.Scope.Organization != "": + variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) { + return c.Actions.ListOrgVariables(ctx, s.Scope.Organization, gitea.ListOrgActionsVariableOption{ListOptions: listOptions(limit, page)}) + }) + default: + err = getJSON(ctx, s.Client, fmt.Sprintf("%s?limit=%d&page=%d", s.repoVariables(""), limit, page), &variables) + } + if err != nil { + return nil, err + } + return convertAll(variables, variableFrom), nil +} + +// View retrieves one variable. +func (s ActionVariables) View(ctx context.Context, name string) (ActionVariable, error) { + variable := new(gitea.ActionsVariable) + var err error + switch { + case s.Scope.Account: + variable, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsVariable, *gitea.Response, error) { + return c.Actions.GetUserVariable(ctx, name) + }) + case s.Scope.Organization != "": + variable, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsVariable, *gitea.Response, error) { + return c.Actions.GetOrgVariable(ctx, s.Scope.Organization, name) + }) + default: + err = getJSON(ctx, s.Client, s.repoVariables(name), variable) + } + if err != nil { + return ActionVariable{}, err + } + return variableFrom(variable), nil +} + +// Set chooses create or update from an authoritative read. +func (s ActionVariables) Set(ctx context.Context, name, value, description string) (ActionVariable, error) { + exists := true + if _, err := s.View(ctx, name); err != nil { + if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound { + return ActionVariable{}, err + } + exists = false + } + create := gitea.CreateActionsVariableOption{Value: value, Description: description} + update := gitea.UpdateActionsVariableOption{Name: name, Value: value, Description: description} + var err error + switch { + case s.Scope.Account: + err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + if exists { + return c.Actions.UpdateUserVariable(ctx, name, update) + } + return c.Actions.CreateUserVariable(ctx, name, create) + }) + case s.Scope.Organization != "": + err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + if exists { + return c.Actions.UpdateOrgVariable(ctx, s.Scope.Organization, name, update) + } + return c.Actions.CreateOrgVariable(ctx, s.Scope.Organization, name, create) + }) + default: + method := http.MethodPost + if exists { + method = http.MethodPut + } + err = sendJSON(ctx, s.Client, method, s.repoVariables(name), update, nil) + } + return ActionVariable{Name: name, Value: value, Description: description}, err +} + +// Delete removes only the named variable in the selected scope. +func (s ActionVariables) Delete(ctx context.Context, name string) error { + switch { + case s.Scope.Account: + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Actions.DeleteUserVariable(ctx, name) + }) + case s.Scope.Organization != "": + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Actions.DeleteOrgVariable(ctx, s.Scope.Organization, name) + }) + } + return sendJSON(ctx, s.Client, http.MethodDelete, s.repoVariables(name), nil, nil) +} diff --git a/internal/services/action_values_test.go b/internal/services/action_values_test.go new file mode 100644 index 00000000..9c7034ff --- /dev/null +++ b/internal/services/action_values_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var actionValueScopes = []struct { + name string + scope ActionScope + path string +}{ + {"repository", ActionScope{Repository: "alice/project"}, "/api/v1/repos/alice/project/actions"}, + {"organization", ActionScope{Organization: "team"}, "/api/v1/orgs/team/actions"}, + {"account", ActionScope{Account: true}, "/api/v1/user/actions"}, +} + +func TestVariableSetSelectsCreateOrUpdate(t *testing.T) { + for _, scope := range actionValueScopes { + for _, test := range []struct { + read int + wantMethod string + }{{http.StatusOK, http.MethodPut}, {http.StatusNotFound, http.MethodPost}, {http.StatusForbidden, ""}} { + t.Run(fmt.Sprintf("%s/%d", scope.name, test.read), func(t *testing.T) { + var mutations []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, scope.path+"/variables/BUILD_MODE", r.URL.Path) + if r.Method == http.MethodGet { + w.WriteHeader(test.read) + _, _ = w.Write([]byte(`{"name":"BUILD_MODE","data":"old"}`)) + return + } + mutations = append(mutations, r.Method) + var payload map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "new\nvalue", payload["value"]) + assert.Equal(t, "build flavor", payload["description"]) + w.WriteHeader(http.StatusNoContent) + })) + item, err := (ActionVariables{Client: client, Scope: scope.scope}).Set(context.Background(), "BUILD_MODE", "new\nvalue", "build flavor") + if test.wantMethod == "" { + var apiErr *transport.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.Status) + require.Empty(t, mutations, "a failed read must not mutate") + return + } + require.NoError(t, err) + require.Equal(t, []string{test.wantMethod}, mutations) + require.Equal(t, ActionVariable{Name: "BUILD_MODE", Value: "new\nvalue", Description: "build flavor"}, item) + }) + } + } +} + +func TestVariableReadsAndDeletesUseTheirScope(t *testing.T) { + for _, scope := range actionValueScopes { + t.Run(scope.name, func(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == scope.path+"/variables": + assert.Equal(t, "20", r.URL.Query().Get("limit")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`[{"name":"MODE","data":"test","description":"build mode","owner_id":1}]`)) + default: + _, _ = w.Write([]byte(`{"name":"MODE","data":"test","description":"build mode"}`)) + } + })) + service := ActionVariables{Client: client, Scope: scope.scope} + want := ActionVariable{Name: "MODE", Value: "test", Description: "build mode"} + items, err := service.List(context.Background(), 20, 2) + require.NoError(t, err) + require.Equal(t, []ActionVariable{want}, items) + item, err := service.View(context.Background(), "MODE") + require.NoError(t, err) + require.Equal(t, want, item) + require.NoError(t, service.Delete(context.Background(), "MODE")) + require.Equal(t, []string{ + "GET " + scope.path + "/variables", "GET " + scope.path + "/variables/MODE", "DELETE " + scope.path + "/variables/MODE", + }, requests) + }) + } +} + +func TestSecretMutationsUseTheirScope(t *testing.T) { + for _, scope := range actionValueScopes { + t.Run(scope.name, func(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.EscapedPath()) + if r.Method == http.MethodPut { + var payload map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, map[string]string{"data": "secret", "description": "deploy key"}, payload) + } + w.WriteHeader(http.StatusNoContent) + })) + service := ActionSecrets{Client: client, Scope: scope.scope} + item, err := service.Set(context.Background(), "TOKEN", "secret", "deploy key") + require.NoError(t, err) + require.Equal(t, ActionSecret{Name: "TOKEN", Description: "deploy key"}, item) + require.NoError(t, service.Delete(context.Background(), "A B")) + require.Equal(t, []string{"PUT " + scope.path + "/secrets/TOKEN", "DELETE " + scope.path + "/secrets/A%20B"}, requests) + }) + } +} + +func TestSecretListReturnsMetadataOnly(t *testing.T) { + for _, scope := range actionValueScopes { + t.Run(scope.name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, scope.path+"/secrets", r.URL.Path) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`[{"name":"TOKEN","data":"never-display","description":"deploy","created_at":"2028-01-02T03:04:05Z"},{"name":"OTHER"}]`)) + })) + items, err := (ActionSecrets{Client: client, Scope: scope.scope}).List(context.Background(), 20, 2) + if scope.scope.Account { + var unsupported *CapabilityError + require.ErrorAs(t, err, &unsupported) + return + } + require.NoError(t, err) + require.Equal(t, []ActionSecret{{Name: "TOKEN", Description: "deploy", CreatedAt: "2028-01-02T03:04:05Z"}, {Name: "OTHER"}}, items) + }) + } +} diff --git a/internal/services/actions.go b/internal/services/actions.go new file mode 100644 index 00000000..21ad5c87 --- /dev/null +++ b/internal/services/actions.go @@ -0,0 +1,304 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Workflow is the stable command representation of an Actions workflow. +type Workflow struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` +} + +func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow { + return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State} +} + +// Run is the stable command representation of an Actions run. +type Run struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ID int64 `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + Branch string `json:"branch"` + Event string `json:"event,omitempty"` + Actor string `json:"actor,omitempty"` + Commit string `json:"commit,omitempty"` + Attempt int64 `json:"attempt,omitempty"` +} +type runResponse struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ID int64 `json:"id"` + DisplayTitle string `json:"display_title"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + HeadBranch string `json:"head_branch"` + Event string `json:"event"` + HeadSHA string `json:"head_sha"` + RunAttempt int64 `json:"run_attempt"` + Actor struct { + Login string `json:"login"` + } `json:"actor"` +} + +func (r runResponse) result() Run { + return Run{ + ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: r.StartedAt, CompletedAt: r.CompletedAt, + Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt, + } +} + +// DispatchResult identifies a workflow run created by dispatch when the server returns details. +type DispatchResult struct { + ID int64 `json:"id"` + URL string `json:"url,omitempty"` +} + +// Actions implements repository workflow and run operations. +type Actions struct { + Client *transport.Client + Repository string +} + +func (s Actions) endpoint() string { return "repos/" + s.Repository + "/actions/" } + +// Workflows lists the repository workflow definitions. +func (s Actions) Workflows(ctx context.Context) ([]Workflow, error) { + response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowResponse, *gitea.Response, error) { + return c.Actions.ListRepoWorkflows(ctx, owner, repo) + }) + if err != nil { + return nil, err + } + return convertAll(response.Workflows, workflowFrom), nil +} + +// Workflow retrieves one workflow definition. +func (s Actions) Workflow(ctx context.Context, id string) (Workflow, error) { + return repoConvert(ctx, s.Client, s.Repository, workflowFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflow, *gitea.Response, error) { + return c.Actions.GetRepoWorkflow(ctx, owner, repo, id) + }) +} + +// Dispatch triggers a workflow and reports the run it created. +func (s Actions) Dispatch(ctx context.Context, id, ref string, inputs map[string]string) (DispatchResult, error) { + details, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.RunDetails, *gitea.Response, error) { + return c.Actions.DispatchRepoWorkflow(ctx, owner, repo, id, gitea.CreateActionsWorkflowDispatchOption{Ref: ref, Inputs: inputs}, true) + }) + if err != nil { + return DispatchResult{}, err + } + return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil +} + +// SetWorkflowEnabled updates whether a workflow can run. +func (s Actions) SetWorkflowEnabled(ctx context.Context, id string, enabled bool) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + if enabled { + return c.Actions.EnableRepoWorkflow(ctx, owner, repo, id) + } + return c.Actions.DisableRepoWorkflow(ctx, owner, repo, id) + }) +} + +// RunListOptions supplies server-side run filters and pagination. +type RunListOptions struct { + Since, Until *time.Time + Limit, Page int + Branch, Event, Actor, Commit, Workflow string + Statuses []string +} + +// Validate rejects unsupported status filters before performing API requests. +func (o RunListOptions) Validate() error { + if o.Limit < 1 || o.Limit > 100 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit { + return errors.New("invalid pagination") + } + for _, status := range o.Statuses { + switch status { + case "pending", "waiting", "requested", "action_required", "queued", "in_progress", "completed", "failure", "success", "skipped", "neutral", "cancelled", "timed_out": //nolint:misspell // Gitea API spelling. + default: + return fmt.Errorf("unsupported run status %q; see tea run list --help", status) + } + } + return nil +} + +const maxRunScanPages = 50 + +// Runs lists a page of runs. +func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + if opts.Since != nil || opts.Until != nil { + first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit + result := []Run{} + matched := 0 + for page := 1; page <= maxRunScanPages; page++ { + scan := opts + scan.Limit = 100 + scan.Page = page + items, err := s.runsPage(ctx, scan) + if err != nil { + return nil, err + } + if len(items) == 0 { + return result, nil + } + for _, item := range items { + if item.StartedAt == "" { + continue + } + started, err := time.Parse(time.RFC3339Nano, item.StartedAt) + if err != nil { + return nil, errors.New("server returned an invalid run start timestamp") + } + if opts.Since != nil && started.Before(*opts.Since) || opts.Until != nil && started.After(*opts.Until) { + continue + } + if matched >= first { + result = append(result, item) + } + matched++ + if matched >= last { + return result, nil + } + } + } + return nil, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100) + } + return s.runsPage(ctx, opts) +} + +func (s Actions) runsPage(ctx context.Context, opts RunListOptions) ([]Run, error) { + var response struct { + Runs []runResponse `json:"workflow_runs"` + } + query := url.Values{"limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}} + for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} { + if value != "" { + query.Set(key, value) + } + } + for _, status := range opts.Statuses { + query.Add("status", status) + } + endpoint := s.endpoint() + "runs" + if opts.Workflow != "" { + endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs" + } + // TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at. + if err := getJSON(ctx, s.Client, endpoint+"?"+query.Encode(), &response); err != nil { + return nil, err + } + result := make([]Run, 0, len(response.Runs)) + for _, item := range response.Runs { + result = append(result, item.result()) + } + return result, nil +} + +// Run retrieves one Actions run. +func (s Actions) Run(ctx context.Context, id int64) (Run, error) { + var response runResponse + // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. + err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response) + return response.result(), err +} + +// Rerun schedules another attempt, optionally restricted to failed jobs. +func (s Actions) Rerun(ctx context.Context, id int64, failedOnly bool) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + if failedOnly { + return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id) + } + _, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id) + return resp, err + }) +} + +// DeleteRun removes the selected run. +func (s Actions) DeleteRun(ctx context.Context, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Actions.DeleteRepoRun(ctx, owner, repo, id) + }) +} + +func completedActionStatus(status string) bool { + switch status { + case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling. + return true + } + return false +} + +// SuccessfulRun recognizes a successful terminal run result. +func SuccessfulRun(run Run) bool { + return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success") +} + +// Watch polls with cancellation and reports only changed run state. +func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, changed func(Run) error) (Run, error) { + if interval <= 0 { + return Run{}, errors.New("watch interval must be positive") + } + last := Run{} + for { + current, err := s.Run(ctx, id) + if err != nil { + return Run{}, err + } + if current != last { + if changed != nil { + if err := changed(current); err != nil { + return Run{}, err + } + } + last = current + } + if completedActionStatus(current.Status) { + return current, nil + } + if err := sleepCtx(ctx, interval); err != nil { + return Run{}, err + } + } +} + +// CancelRun requests cancellation without deleting the run or its history. +// Force cancellation is an explicit operation for unresponsive runners. +func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) { + action := "cancel" + if force { + action = "force-cancel" + } + var response runResponse + // TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at. + err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response) + return response.result(), err +} diff --git a/internal/services/actions_test.go b/internal/services/actions_test.go new file mode 100644 index 00000000..58b7d097 --- /dev/null +++ b/internal/services/actions_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestDispatchSendsOneRequest(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusUnprocessableEntity, http.StatusInternalServerError} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + dispatches := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dispatches++ + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/repos/owner/repo/actions/workflows/ci.yml/dispatches", r.URL.Path) + require.Equal(t, "true", r.URL.Query().Get("return_run_details")) + var payload struct { + Ref string `json:"ref"` + Inputs map[string]string `json:"inputs"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "main", payload.Ref) + require.Equal(t, map[string]string{"mode": "fast"}, payload.Inputs) + w.WriteHeader(status) + if status == http.StatusOK { + _, _ = w.Write([]byte(`{"workflow_run_id":11,"html_url":"https://gitea.test/owner/repo/actions/runs/11"}`)) + } + })) + result, err := (Actions{Client: client, Repository: "owner/repo"}).Dispatch(context.Background(), "ci.yml", "main", map[string]string{"mode": "fast"}) + require.Equal(t, 1, dispatches) + if status == http.StatusOK { + require.NoError(t, err) + require.Equal(t, DispatchResult{ID: 11, URL: "https://gitea.test/owner/repo/actions/runs/11"}, result) + return + } + var apiErr *transport.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, status, apiErr.Status) + require.Zero(t, result) + }) + } +} + +func TestWorkflowsMapDefinitions(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + switch r.URL.Path { + case "/api/v1/repos/owner/repo/actions/workflows": + _, _ = w.Write([]byte(`{"total_count":1,"workflows":[{"id":"ci.yml","name":"CI","path":".gitea/workflows/ci.yml","state":"active","badge_url":"excluded"}]}`)) + case "/api/v1/repos/owner/repo/actions/workflows/ci.yml": + _, _ = w.Write([]byte(`{"id":"ci.yml","name":"CI","path":".gitea/workflows/ci.yml","state":"disabled_manually"}`)) + default: + t.Errorf("unexpected %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + service := Actions{Client: client, Repository: "owner/repo"} + items, err := service.Workflows(context.Background()) + require.NoError(t, err) + require.Equal(t, []Workflow{{ID: "ci.yml", Name: "CI", Path: ".gitea/workflows/ci.yml", State: "active"}}, items) + item, err := service.Workflow(context.Background(), "ci.yml") + require.NoError(t, err) + require.Equal(t, "disabled_manually", item.State) +} + +func TestWatchChangesAndFailureConclusion(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls <= 2 { + _, _ = w.Write([]byte(`{"id":1,"status":"in_progress"}`)) + } else { + _, _ = w.Write([]byte(`{"id":1,"status":"completed","conclusion":"failure"}`)) + } + })) + defer server.Close() + changes := []Run{} + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error { changes = append(changes, run); return nil }) + require.NoError(t, err) + require.Len(t, changes, 2) + require.False(t, SuccessfulRun(final)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = service.Watch(ctx, 1, time.Millisecond, nil) + require.True(t, errors.Is(err, context.Canceled)) +} + +func TestRunCancellationPreservesHistory(t *testing.T) { + for _, force := range []bool{false, true} { + t.Run(fmt.Sprint(force), func(t *testing.T) { + mutations := 0 + action := "cancel" + if force { + action = "force-cancel" + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutations++ + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/"+action, r.URL.Path) + _, _ = w.Write([]byte(`{"id":7,"status":"completed","conclusion":"cancelled"}`)) //nolint:misspell // Gitea API spelling. + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + result, err := service.CancelRun(context.Background(), 7, force) + require.NoError(t, err) + require.EqualValues(t, 7, result.ID) + require.Equal(t, "cancelled", result.Conclusion) //nolint:misspell // Gitea API spelling. + require.Equal(t, 1, mutations) + }) + } +} + +func TestActionMutationsUseTheirEndpoints(t *testing.T) { + for _, tc := range []struct { + name string + path string + method string + call func(Actions) error + }{ + {"enable", "/api/v1/repos/owner/repo/actions/workflows/ci.yml/enable", http.MethodPut, func(s Actions) error { + return s.SetWorkflowEnabled(context.Background(), "ci.yml", true) + }}, + {"disable", "/api/v1/repos/owner/repo/actions/workflows/ci.yml/disable", http.MethodPut, func(s Actions) error { + return s.SetWorkflowEnabled(context.Background(), "ci.yml", false) + }}, + {"rerun", "/api/v1/repos/owner/repo/actions/runs/7/rerun", http.MethodPost, func(s Actions) error { + return s.Rerun(context.Background(), 7, false) + }}, + {"rerun-failed", "/api/v1/repos/owner/repo/actions/runs/7/rerun-failed-jobs", http.MethodPost, func(s Actions) error { + return s.Rerun(context.Background(), 7, true) + }}, + {"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error { + return s.DeleteRun(context.Background(), 7) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutations++ + require.Equal(t, tc.method, r.Method) + require.Equal(t, tc.path, r.URL.Path) + if tc.name == "rerun" { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":7,"status":"waiting"}`)) + return + } + w.WriteHeader(http.StatusNoContent) + })) + service := Actions{Client: client, Repository: "owner/repo"} + require.NoError(t, tc.call(service)) + require.Equal(t, 1, mutations) + }) + } +} + +func TestTerminalActionsRecognizeGiteaAndLegacyStatuses(t *testing.T) { + for _, status := range []string{"completed", "success", "failure", "canceled", "cancelled", "skipped"} { //nolint:misspell // Gitea API spelling. + require.True(t, completedActionStatus(status), status) + } + for _, status := range []string{"queued", "waiting", "pending", "running", "in_progress", "cancelling", "blocked", "unknown"} { //nolint:misspell // Gitea API spelling. + require.False(t, completedActionStatus(status), status) + require.False(t, SuccessfulRun(Run{Status: status, Conclusion: "success"}), status) + } + require.True(t, SuccessfulRun(Run{Status: "completed", Conclusion: "success"})) + require.True(t, SuccessfulRun(Run{Status: "success"})) + require.False(t, SuccessfulRun(Run{Status: "completed", Conclusion: "failure"})) +} + +func TestRunFiltersEncodeServerQueriesAndRejectInvalidStatus(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + require.Equal(t, "/api/v1/repos/owner/repo/actions/workflows/ci.yml/runs", r.URL.Path) + require.Equal(t, []string{"queued", "failure"}, r.URL.Query()["status"]) + require.Equal(t, "feature/a b", r.URL.Query().Get("branch")) + require.Equal(t, "push", r.URL.Query().Get("event")) + require.Equal(t, "alice", r.URL.Query().Get("actor")) + require.Equal(t, "abc123", r.URL.Query().Get("head_sha")) + _, _ = w.Write([]byte(`{"workflow_runs":[]}`)) + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + opts := RunListOptions{Limit: 20, Page: 2, Branch: "feature/a b", Event: "push", Actor: "alice", Commit: "abc123", Workflow: "ci.yml", Statuses: []string{"queued", "failure"}} + items, err := service.Runs(context.Background(), opts) + require.NoError(t, err) + require.NotNil(t, items) + require.Equal(t, 1, calls) + opts.Statuses = []string{"unknown"} + _, err = service.Runs(context.Background(), opts) + require.ErrorContains(t, err, "unsupported run status") + require.Equal(t, 1, calls) +} + +func TestRunTimeFilteringPaginatesMatchingResults(t *testing.T) { + since := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"started_at":"2027-12-31T00:00:00Z"},{"id":2,"started_at":"2028-01-01T00:00:00Z"}]}`)) + case "2": + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"started_at":"2028-01-02T00:00:00Z"}]}`)) + default: + _, _ = w.Write([]byte(`{"workflow_runs":[]}`)) + } + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + items, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 2, Since: &since}) + require.NoError(t, err) + require.Len(t, items, 1) + require.EqualValues(t, 3, items[0].ID) +} + +func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { + since := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"started_at":"2027-12-31T00:00:00Z"}]}`)) + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + _, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 1, Since: &since}) + require.ErrorContains(t, err, "narrow the search") + require.Equal(t, maxRunScanPages, pages) +} diff --git a/internal/services/admin_users.go b/internal/services/admin_users.go new file mode 100644 index 00000000..265709cc --- /dev/null +++ b/internal/services/admin_users.go @@ -0,0 +1,152 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// AdminUser is the explicit account-management result, without authentication secrets. +type AdminUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + Description string `json:"description"` + Website string `json:"website"` + Location string `json:"location"` + URL string `json:"url"` + Admin bool `json:"admin"` + Active bool `json:"active"` + Restricted bool `json:"restricted"` + ProhibitLogin bool `json:"prohibit_login"` + Visibility string `json:"visibility"` + CreatedAt string `json:"created_at"` +} + +func adminUserFrom(user *gitea.User) AdminUser { + return AdminUser{ + ID: user.ID, Username: user.UserName, Email: user.Email, FullName: user.FullName, Description: user.Description, + Website: user.Website, Location: user.Location, URL: user.HTMLURL, Admin: user.IsAdmin, Active: user.IsActive, + Restricted: user.Restricted, ProhibitLogin: user.ProhibitLogin, Visibility: string(user.Visibility), CreatedAt: timestamp(user.Created), + } +} + +// AdminUserOptions distinguishes omitted fields from explicit empty or false values. +type AdminUserOptions struct { + Email *string `json:"email,omitempty"` + FullName *string `json:"full_name,omitempty"` + Description *string `json:"description,omitempty"` + Website *string `json:"website,omitempty"` + Location *string `json:"location,omitempty"` + Visibility *string `json:"visibility,omitempty"` + Password string `json:"password,omitempty"` + Admin *bool `json:"admin,omitempty"` + Active *bool `json:"active,omitempty"` + Restricted *bool `json:"restricted,omitempty"` + ProhibitLogin *bool `json:"prohibit_login,omitempty"` + MustChangePassword *bool `json:"must_change_password,omitempty"` + AllowGitHook *bool `json:"allow_git_hook,omitempty"` + AllowImportLocal *bool `json:"allow_import_local,omitempty"` + AllowCreateOrganization *bool `json:"allow_create_organization,omitempty"` + MaxRepoCreation *int `json:"max_repo_creation,omitempty"` +} + +func visibilityOf(visibility *string) *gitea.VisibleType { + if visibility == nil { + return nil + } + value := gitea.VisibleType(*visibility) + return &value +} + +// AdminUsers provides administrator account operations. +type AdminUsers struct{ Client *transport.Client } + +// List returns a page of users visible to the instance administrator. +func (s AdminUsers) List(ctx context.Context, limit, page int) ([]AdminUser, error) { + users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { + return c.Admin.ListUsers(ctx, gitea.AdminListUsersOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(users, adminUserFrom), nil +} + +func (s AdminUsers) get(ctx context.Context, username string) (*gitea.User, error) { + user, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetUserInfo(ctx, username) + }) + return user, err +} + +// View retrieves one account. +func (s AdminUsers) View(ctx context.Context, username string) (AdminUser, error) { + user, err := s.get(ctx, username) + if err != nil { + return AdminUser{}, err + } + return adminUserFrom(user), nil +} + +// Create creates an account, then applies administrative fields unavailable during creation. +func (s AdminUsers) Create(ctx context.Context, username string, opts AdminUserOptions) (AdminUser, error) { + created, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Admin.CreateUser(ctx, gitea.CreateUserOption{ + Username: username, LoginName: username, Email: deref(opts.Email), FullName: deref(opts.FullName), + Password: opts.Password, MustChangePassword: opts.MustChangePassword, Visibility: visibilityOf(opts.Visibility), + }) + }) + if err != nil { + return AdminUser{}, err + } + if opts.Admin == nil && opts.Restricted == nil && opts.ProhibitLogin == nil { + return adminUserFrom(created), nil + } + followup := AdminUserOptions{Admin: opts.Admin, Restricted: opts.Restricted, ProhibitLogin: opts.ProhibitLogin} + if err := s.edit(ctx, username, created, followup); err != nil { + return adminUserFrom(created), fmt.Errorf("user %s created but account restrictions could not be applied: %w", username, err) + } + item, err := s.View(ctx, username) + if err != nil { + return adminUserFrom(created), fmt.Errorf("user %s created but could not be reloaded: %w", username, err) + } + return item, nil +} + +func (s AdminUsers) edit(ctx context.Context, username string, current *gitea.User, opts AdminUserOptions) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Admin.EditUser(ctx, username, gitea.EditUserOption{ + SourceID: current.SourceID, LoginName: current.LoginName, Email: opts.Email, FullName: opts.FullName, Password: opts.Password, + Description: opts.Description, MustChangePassword: opts.MustChangePassword, Website: opts.Website, Location: opts.Location, + Active: opts.Active, Admin: opts.Admin, AllowGitHook: opts.AllowGitHook, AllowImportLocal: opts.AllowImportLocal, + MaxRepoCreation: opts.MaxRepoCreation, ProhibitLogin: opts.ProhibitLogin, AllowCreateOrganization: opts.AllowCreateOrganization, + Restricted: opts.Restricted, Visibility: visibilityOf(opts.Visibility), + }) + }) +} + +// Edit preserves the authentication identity while updating account properties. +func (s AdminUsers) Edit(ctx context.Context, username string, opts AdminUserOptions) (AdminUser, error) { + current, err := s.get(ctx, username) + if err != nil { + return AdminUser{}, err + } + if err := s.edit(ctx, username, current, opts); err != nil { + return AdminUser{}, err + } + return s.View(ctx, username) +} + +// Delete removes an account. Gitea enforces ownership constraints. +func (s AdminUsers) Delete(ctx context.Context, username string) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Admin.DeleteUser(ctx, username) + }) +} diff --git a/internal/services/admin_users_test.go b/internal/services/admin_users_test.go new file mode 100644 index 00000000..d2db78fa --- /dev/null +++ b/internal/services/admin_users_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestAdminEditPreservesAuthenticationAndExplicitFalse(t *testing.T) { + mutations, reads := 0, 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + reads++ + require.Equal(t, "/api/v1/users/account", r.URL.Path) + if mutations == 0 { + _, _ = w.Write([]byte(`{"id":1,"login":"account","login_name":"external-identity","source_id":7,"is_admin":true}`)) + return + } + _, _ = w.Write([]byte(`{"id":1,"login":"account","is_admin":false,"created":"2026-01-02T03:04:05Z"}`)) + return + } + mutations++ + require.Equal(t, http.MethodPatch, r.Method) + require.Equal(t, "/api/v1/admin/users/account", r.URL.Path) + payload := requestJSON(t, r) + require.Equal(t, "external-identity", payload["login_name"]) + require.EqualValues(t, 7, payload["source_id"]) + require.Equal(t, false, payload["admin"]) + require.Equal(t, "", payload["description"]) + require.Empty(t, payload["password"], "an empty password leaves it unchanged") + require.Nil(t, payload["active"], "an omitted flag leaves it unchanged") + w.WriteHeader(http.StatusNoContent) + })) + admin := false + description := "" + item, err := (AdminUsers{Client: client}).Edit(context.Background(), "account", AdminUserOptions{Admin: &admin, Description: &description}) + require.NoError(t, err) + require.Equal(t, "account", item.Username) + require.False(t, item.Admin) + require.Equal(t, "2026-01-02T03:04:05Z", item.CreatedAt) + require.Equal(t, 1, mutations) + require.Equal(t, 2, reads) +} + +func TestAdminCreateAppliesRestrictionsAndReloads(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + switch r.Method { + case http.MethodPost: + payload := requestJSON(t, r) + require.Equal(t, "new-account", payload["username"]) + require.Equal(t, "new-account", payload["login_name"]) + require.Equal(t, "private", payload["visibility"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":4,"login":"new-account","login_name":"new-account"}`)) + case http.MethodPatch: + payload := requestJSON(t, r) + require.Equal(t, true, payload["restricted"]) + w.WriteHeader(http.StatusNoContent) + default: + _, _ = w.Write([]byte(`{"id":4,"login":"new-account","restricted":true}`)) + } + })) + restricted := true + email, visibility := "account@example.test", "private" + item, err := (AdminUsers{Client: client}).Create(context.Background(), "new-account", AdminUserOptions{Email: &email, Password: "local-password", Visibility: &visibility, Restricted: &restricted}) + require.NoError(t, err) + require.True(t, item.Restricted) + require.Equal(t, []string{"POST /api/v1/admin/users", "PATCH /api/v1/admin/users/new-account", "GET /api/v1/users/new-account"}, requests) +} + +func TestAdminCreateReportsFollowupFailureWithoutRetrying(t *testing.T) { + creates, edits := 0, 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + creates++ + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":4,"login":"new-account","login_name":"new-account"}`)) + return + } + edits++ + w.WriteHeader(http.StatusForbidden) + })) + admin := true + email := "account@example.test" + item, err := (AdminUsers{Client: client}).Create(context.Background(), "new-account", AdminUserOptions{Email: &email, Password: "local-password", Admin: &admin}) + require.ErrorContains(t, err, "new-account created") + require.NotContains(t, err.Error(), "local-password") + require.EqualValues(t, 4, item.ID) + require.Equal(t, 1, creates) + require.Equal(t, 1, edits) +} + +func TestAdminUserFromMapsServerFields(t *testing.T) { + user := decode[gitea.User](t, `{"id":3,"login":"alice","email":"alice@example.test","full_name":"Alice","description":"Admin", + "website":"https://alice.test","location":"Earth","html_url":"https://gitea.test/alice","is_admin":true,"active":true, + "restricted":true,"prohibit_login":true,"visibility":"limited","created":"2026-10-01T12:00:00+02:00"}`) + require.Equal(t, AdminUser{ + ID: 3, Username: "alice", Email: "alice@example.test", FullName: "Alice", Description: "Admin", Website: "https://alice.test", + Location: "Earth", URL: "https://gitea.test/alice", Admin: true, Active: true, Restricted: true, ProhibitLogin: true, + Visibility: "limited", CreatedAt: "2026-10-01T12:00:00+02:00", + }, adminUserFrom(user)) +} diff --git a/internal/services/api.go b/internal/services/api.go new file mode 100644 index 00000000..cc2ef647 --- /dev/null +++ b/internal/services/api.go @@ -0,0 +1,155 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "io" + "mime" + "net/http" + "net/url" + "slices" + "strings" + + "gitea.dev/tea/internal/transport" +) + +// API provides the raw escape hatch while keeping credentials scoped to one instance. +type API struct{ Client *transport.Client } + +// APIOptions describes a raw request and its bounded pagination policy. +type APIOptions struct { + Endpoint string + Method string + Body io.Reader + Headers http.Header + Paginate bool + MaxPages int +} + +func nextPageLinks(headers []string) ([]string, error) { + result := []string{} + for _, header := range headers { + start := 0 + quoted, angle, escaped := false, false, false + parts := []string{} + for index, char := range header { + if escaped { + escaped = false + continue + } + if quoted && char == '\\' { + escaped = true + continue + } + if char == '"' && !angle { + quoted = !quoted + } + if !quoted { + if char == '<' { + angle = true + } + if char == '>' { + angle = false + } + if char == ',' && !angle { + parts = append(parts, header[start:index]) + start = index + 1 + } + } + } + if quoted || angle { + return nil, errors.New("API returned an invalid pagination Link header") + } + parts = append(parts, header[start:]) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + endpoint, parameters, ok := strings.Cut(part, ">") + if !ok || !strings.HasPrefix(endpoint, "<") { + return nil, errors.New("API returned an invalid pagination Link header") + } + _, params, err := mime.ParseMediaType("link" + parameters) + if err != nil { + return nil, errors.New("API returned invalid pagination parameters") + } + if slices.Contains(strings.Fields(params["rel"]), "next") { + result = append(result, strings.TrimPrefix(endpoint, "<")) + } + } + } + return result, nil +} + +// Visit delivers responses in order and closes each body after the visitor. +// A response outside 2xx is still visited, then ends the visit as an *transport.APIError. +// Pagination follows only scoped links and never retries a mutation. +func (s API) Visit(ctx context.Context, options APIOptions, visit func(*http.Response) error) error { + if options.Paginate && (options.Method != http.MethodGet || options.Body != nil) { + return errors.New("pagination requires a body-free GET request") + } + if options.MaxPages < 1 { + options.MaxPages = 1000 + } + endpoint := options.Endpoint + seen := map[string]bool{} + for page := 0; ; page++ { + absolute, err := s.Client.URL(endpoint) + if err != nil { + return err + } + if seen[absolute] { + return errors.New("API pagination link repeats an earlier page") + } + seen[absolute] = true + current, _ := url.Parse(absolute) + response, err := s.Client.Send(ctx, options.Method, endpoint, options.Body, options.Headers) + if err != nil { + return err + } + links := response.Header.Values("Link") + err = visit(response) + closeErr := response.Body.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &transport.APIError{Status: response.StatusCode, Method: options.Method, Endpoint: current.Path} + } + if !options.Paginate { + return nil + } + nextLinks, err := nextPageLinks(links) + if err != nil { + return err + } + if len(nextLinks) > 1 { + return errors.New("API returned multiple next-page links") + } + next := "" + if len(nextLinks) == 1 { + next = nextLinks[0] + } + if next == "" { + return nil + } + if page+1 >= options.MaxPages { + return errors.New("API pagination exceeded --max-pages") + } + if _, err := s.Client.URL(next); err != nil { + return err + } + relative, err := url.Parse(next) + if err != nil || strings.Contains(next, "\\") { + return errors.New("API returned an invalid pagination link") + } + endpoint = current.ResolveReference(relative).String() + } +} diff --git a/internal/services/api_test.go b/internal/services/api_test.go new file mode 100644 index 00000000..309c93eb --- /dev/null +++ b/internal/services/api_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestAPIPaginationRejectsUnsafeAndRepeatingLinks(t *testing.T) { + foreignCalls := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { foreignCalls++ })) + defer foreign.Close() + for _, next := range []string{foreign.URL + "/api/v1/user?secret=hidden", "/api/v1/items", "/unrelated/items", "/api/v1/../user"} { + t.Run(next, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + require.Equal(t, "token secret", r.Header.Get("Authorization")) + w.Header().Set("Link", "<"+next+">; rel=next") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + err := (API{Client: transport.New(server.URL, "secret", false)}).Visit(context.Background(), APIOptions{Endpoint: "items", Method: "GET", Paginate: true}, func(response *http.Response) error { _, err := io.Copy(io.Discard, response.Body); return err }) + require.Error(t, err) + require.NotContains(t, err.Error(), "hidden") + require.Equal(t, 1, calls) + }) + } + require.Zero(t, foreignCalls) + links, err := nextPageLinks([]string{`; title="one,two"; rel="prev next", ; rel=last`}) + require.NoError(t, err) + require.Equal(t, []string{"https://example.org/items?a=1,2"}, links) +} diff --git a/internal/services/artifacts.go b/internal/services/artifacts.go new file mode 100644 index 00000000..9267f777 --- /dev/null +++ b/internal/services/artifacts.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "io" + + gitea "gitea.dev/sdk" +) + +// Artifact is the stable metadata for a downloadable Actions artifact. +type Artifact struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size_in_bytes"` + URL string `json:"url"` + Expired bool `json:"expired"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ExpiresAt *string `json:"expires_at"` +} + +func artifactFrom(artifact *gitea.ActionsArtifact) Artifact { + return Artifact{ + ID: artifact.ID, Name: artifact.Name, Size: artifact.SizeInBytes, URL: artifact.URL, Expired: artifact.Expired, + CreatedAt: timestamp(artifact.CreatedAt), UpdatedAt: timestamp(artifact.UpdatedAt), ExpiresAt: optionalTimestamp(&artifact.ExpiresAt), + } +} + +// Artifacts lists one page of artifacts belonging to a run. +func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) ([]Artifact, error) { + response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifactsResponse, *gitea.Response, error) { + return c.Actions.ListRepoRunArtifacts(ctx, owner, repo, run, gitea.ListActionsArtifactsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(response.Artifacts, artifactFrom), nil +} + +// DownloadArtifact streams an artifact ZIP, accepting a scoped signed redirect. +func (s Actions) DownloadArtifact(ctx context.Context, id int64) (io.ReadCloser, error) { + endpoint, err := s.Client.URL(fmt.Sprintf("%sartifacts/%d/zip", s.endpoint(), id)) + if err != nil { + return nil, err + } + response, err := s.Client.DownloadSigned(ctx, endpoint) + if err != nil { + return nil, err + } + return response.Body, nil +} diff --git a/internal/services/artifacts_test.go b/internal/services/artifacts_test.go new file mode 100644 index 00000000..fe4856d2 --- /dev/null +++ b/internal/services/artifacts_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestArtifactListingProjectsMetadataAndEmptyArrays(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/artifacts", r.URL.Path) + require.Equal(t, "20", r.URL.Query().Get("limit")) + if r.URL.Query().Get("page") == "2" { + _, _ = w.Write([]byte(`{"artifacts":null,"total_count":0}`)) + return + } + _, _ = w.Write([]byte(`{"artifacts":[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"expires_at":null,"workflow_run":{"head_sha":"excluded"},"archive_download_url":"excluded"}]}`)) + })) + service := Actions{Client: client, Repository: "owner/repo"} + items, err := service.Artifacts(context.Background(), 7, 20, 1) + require.NoError(t, err) + require.Len(t, items, 1) + data, err := json.Marshal(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"created_at":"","updated_at":"","expires_at":null}]`, string(data)) + items, err = service.Artifacts(context.Background(), 7, 20, 2) + require.NoError(t, err) + require.Empty(t, items) + require.NotNil(t, items) +} + +func TestArtifactFromMapsServerFields(t *testing.T) { + artifact := decode[gitea.ActionsArtifact](t, `{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":true, + "created_at":"2028-01-02T03:04:05Z","updated_at":"2028-01-02T03:04:05Z","expires_at":"2028-01-03T03:04:05Z"}`) + expires := "2028-01-03T03:04:05Z" + require.Equal(t, Artifact{ + ID: 9, Name: "build", Size: 128, URL: "https://example.test/artifact", Expired: true, + CreatedAt: "2028-01-02T03:04:05Z", UpdatedAt: "2028-01-02T03:04:05Z", ExpiresAt: &expires, + }, artifactFrom(artifact)) +} diff --git a/internal/services/attachments.go b/internal/services/attachments.go new file mode 100644 index 00000000..3730bc60 --- /dev/null +++ b/internal/services/attachments.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// ReleaseAsset is the public metadata of a release attachment. +type ReleaseAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + DownloadCount int64 `json:"download_count"` +} + +// IssueAttachment describes an attachment owned by an issue or pull request. +type IssueAttachment ReleaseAsset + +func assetFrom(attachment *gitea.Attachment) ReleaseAsset { + return ReleaseAsset{ + ID: attachment.ID, Name: attachment.Name, Size: attachment.Size, URL: attachment.DownloadURL, + CreatedAt: timestamp(attachment.Created), DownloadCount: attachment.DownloadCount, + } +} + +// uploadAttachment streams a multipart upload to an attachment collection endpoint without +// buffering the file in memory. +// TODO(sdk): CreateReleaseAttachment and CreateIssueAttachment buffer the whole file. +func uploadAttachment(ctx context.Context, client *transport.Client, endpoint, name string, input io.Reader) (ReleaseAsset, error) { + reader, writer := io.Pipe() + multipartWriter := multipart.NewWriter(writer) + defer reader.Close() + go func() { + part, err := multipartWriter.CreateFormFile("attachment", name) + if err == nil { + _, err = io.Copy(part, input) + } + if err == nil { + err = multipartWriter.Close() + } + _ = writer.CloseWithError(err) + }() + response, err := client.Do(ctx, http.MethodPost, endpoint, reader, http.Header{"Content-Type": {multipartWriter.FormDataContentType()}}) + if err != nil { + return ReleaseAsset{}, err + } + defer response.Body.Close() + var attachment gitea.Attachment + if err := json.NewDecoder(response.Body).Decode(&attachment); err != nil { + if ctx.Err() != nil { + return ReleaseAsset{}, ctx.Err() + } + return ReleaseAsset{}, errInvalidJSON + } + return assetFrom(&attachment), nil +} + +// downloadAttachment opens the content of an attachment read from its owning release or issue, +// following one signed storage redirect without credentials. +func downloadAttachment(ctx context.Context, client *transport.Client, attachment *gitea.Attachment) (io.ReadCloser, error) { + if attachment.UUID == "" || strings.ContainsAny(attachment.UUID, "/\\.") { + return nil, errors.New("invalid attachment identifier from Gitea") + } + response, err := client.DownloadSigned(ctx, strings.TrimRight(client.BaseURL, "/")+"/attachments/"+url.PathEscape(attachment.UUID)) + if err != nil { + return nil, err + } + return response.Body, nil +} diff --git a/internal/services/branches.go b/internal/services/branches.go new file mode 100644 index 00000000..af181511 --- /dev/null +++ b/internal/services/branches.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Branch describes a remote branch and its protection. +type Branch struct { + Name string `json:"name"` + SHA string `json:"sha"` + Protected bool `json:"protected"` + RequiredApprovals int64 `json:"required_approvals"` + CanPush bool `json:"can_push"` + CanMerge bool `json:"can_merge"` +} + +func branchFrom(branch *gitea.Branch) Branch { + result := Branch{ + Name: branch.Name, Protected: branch.Protected, RequiredApprovals: branch.RequiredApprovals, + CanPush: branch.UserCanPush, CanMerge: branch.UserCanMerge, + } + if branch.Commit != nil { + result.SHA = branch.Commit.ID + } + return result +} + +// Branches operates on remote branches and protection rules. +type Branches struct{ Client *transport.Client } + +// List returns a page of remote branches. +func (s Branches) List(ctx context.Context, repo string, limit, page int) ([]Branch, error) { + branches, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Branch, *gitea.Response, error) { + return c.Repositories.ListRepoBranches(ctx, owner, name, gitea.ListRepoBranchesOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(branches, branchFrom), nil +} + +// View retrieves a remote branch. +func (s Branches) View(ctx context.Context, repo, branch string) (Branch, error) { + return repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) { + return c.Repositories.GetRepoBranch(ctx, owner, name, branch) + }) +} + +// Create creates a branch from the supplied source or repository default. +func (s Branches) Create(ctx context.Context, repo, branch, source string) (Branch, error) { + return repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) { + return c.Repositories.CreateBranch(ctx, owner, name, gitea.CreateBranchOption{BranchName: branch, OldBranchName: source}) + }) +} + +// Rename renames a remote branch. +func (s Branches) Rename(ctx context.Context, repo, oldName, newName string) error { + return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + _, resp, err := c.Repositories.RenameRepoBranch(ctx, owner, name, oldName, gitea.RenameRepoBranchOption{Name: newName}) + return resp, err + }) +} + +// Delete removes a remote branch. +func (s Branches) Delete(ctx context.Context, repo, branch string) error { + return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + _, resp, err := c.Repositories.DeleteRepoBranch(ctx, owner, name, branch) + return resp, err + }) +} + +// Protect blocks direct pushes to the branch and requires one approving review. +func (s Branches) Protect(ctx context.Context, repo, branch string) error { + return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + _, resp, err := c.Repositories.CreateBranchProtection(ctx, owner, name, gitea.CreateBranchProtectionOption{ + BranchName: branch, RuleName: branch, RequiredApprovals: 1, + }) + return resp, err + }) +} + +// Unprotect removes the named branch protection rule. +func (s Branches) Unprotect(ctx context.Context, repo, branch string) error { + return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + return c.Repositories.DeleteBranchProtection(ctx, owner, name, branch) + }) +} diff --git a/internal/services/branches_test.go b/internal/services/branches_test.go new file mode 100644 index 00000000..663f4138 --- /dev/null +++ b/internal/services/branches_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBranchRenameEscapesNames(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutations++ + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/owner/repo/branches/feature/a%23b", r.URL.EscapedPath()) + var payload map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, map[string]string{"name": "feature/renamed"}, payload) + w.WriteHeader(http.StatusNoContent) + })) + err := (Branches{Client: client}).Rename(context.Background(), "owner/repo", "feature/a#b", "feature/renamed") + require.NoError(t, err) + require.Equal(t, 1, mutations) +} + +func TestBranchListMapsServerFields(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/branches", r.URL.Path) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + assert.Equal(t, "3", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"},"protected":true,"required_approvals":2,"user_can_push":true,"user_can_merge":true},{"name":"orphan","commit":null}]`)) + })) + items, err := (Branches{Client: client}).List(context.Background(), "owner/repo", 20, 3) + require.NoError(t, err) + require.Equal(t, []Branch{ + {Name: "main", SHA: "abc", Protected: true, RequiredApprovals: 2, CanPush: true, CanMerge: true}, + {Name: "orphan"}, + }, items) +} + +func TestBranchCreateAndProtectPayloads(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload := requestJSON(t, r) + switch r.URL.Path { + case "/api/v1/repos/owner/repo/branches": + assert.Equal(t, "feature", payload["new_branch_name"]) + assert.Equal(t, "", payload["old_branch_name"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"feature","commit":{"id":"abc"}}`)) + case "/api/v1/repos/owner/repo/branch_protections": + assert.Equal(t, "main", payload["branch_name"]) + assert.Equal(t, "main", payload["rule_name"]) + assert.Equal(t, false, payload["enable_push"]) + assert.EqualValues(t, 1, payload["required_approvals"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"rule_name":"main"}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + service := Branches{Client: client} + item, err := service.Create(context.Background(), "owner/repo", "feature", "") + require.NoError(t, err) + require.Equal(t, Branch{Name: "feature", SHA: "abc"}, item) + require.NoError(t, service.Protect(context.Background(), "owner/repo", "main")) +} diff --git a/internal/services/checks.go b/internal/services/checks.go new file mode 100644 index 00000000..cb97cb51 --- /dev/null +++ b/internal/services/checks.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "slices" + "sort" + "time" + + gitea "gitea.dev/sdk" +) + +// Check represents the latest status of one commit context. +type Check struct { + SHA string `json:"sha"` + ID int64 `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Description string `json:"description"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Pending reports whether the check has not finished yet. +func (c Check) Pending() bool { return c.State == "pending" } + +// Passed reports whether the check finished without blocking a merge; Gitea counts skipped as success. +func (c Check) Passed() bool { return c.State == "success" || c.State == "skipped" } + +// Checks returns latest contexts for the PR head in its base repository, as Gitea does. +func (s Pulls) Checks(ctx context.Context, n int64) ([]Check, error) { + sha, err := s.checkHead(ctx, n) + if err != nil { + return nil, err + } + return s.checksForCommit(ctx, sha) +} + +func (s Pulls) checkHead(ctx context.Context, n int64) (string, error) { + pull, err := s.get(ctx, n) + if err != nil { + return "", err + } + if pull.Head == nil || pull.Head.Sha == "" { + return "", errors.New("Gitea did not return the pull request head commit") + } + return pull.Head.Sha, nil +} + +func checkFrom(sha string, status *gitea.Status) Check { + return Check{ + SHA: sha, ID: status.ID, Name: status.Context, State: string(status.State), Description: status.Description, + URL: status.TargetURL, CreatedAt: timestamp(status.Created), UpdatedAt: timestamp(status.Updated), + } +} + +func (s Pulls) checksForCommit(ctx context.Context, sha string) ([]Check, error) { + statuses, err := listAll(func(page int) ([]*gitea.Status, error) { + return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Status, *gitea.Response, error) { + return c.Repositories.ListStatusesBySHA(ctx, owner, name, sha, gitea.ListStatusesOption{ListOptions: listOptions(100, page)}) + }) + }) + if err != nil { + return nil, err + } + latest := map[string]Check{} + for _, status := range statuses { + current, exists := latest[status.Context] + if !exists || status.ID > current.ID { + latest[status.Context] = checkFrom(sha, status) + } + } + result := make([]Check, 0, len(latest)) + for _, check := range latest { + result = append(result, check) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, nil +} + +// WatchChecks follows the current PR head until contexts finish or a check fails. +// An empty status set waits for checks to be registered instead of claiming success. +func (s Pulls) WatchChecks(ctx context.Context, n int64, interval time.Duration, changed func([]Check) error) ([]Check, error) { + if interval <= 0 { + return nil, errors.New("check polling interval must be positive") + } + var previous []Check + for { + sha, err := s.checkHead(ctx, n) + if err != nil { + return nil, err + } + items, err := s.checksForCommit(ctx, sha) + if err != nil { + return nil, err + } + if !slices.Equal(items, previous) { + if changed != nil { + if err := changed(items); err != nil { + return nil, err + } + } + previous = items + } + pending := len(items) == 0 + failed := false + for _, item := range items { + if item.Pending() { + pending = true + } else if !item.Passed() { + failed = true + } + } + if failed || !pending { + current, err := s.checkHead(ctx, n) + if err != nil { + return nil, err + } + if current == sha { + return items, nil + } + } + if err := sleepCtx(ctx, interval); err != nil { + return nil, err + } + } +} diff --git a/internal/services/checks_test.go b/internal/services/checks_test.go new file mode 100644 index 00000000..15e58ef8 --- /dev/null +++ b/internal/services/checks_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestCheckWatchRevalidatesHeadBeforeReturning(t *testing.T) { + heads := 0 + newSnapshots := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/pulls/") { + heads++ + sha := "old" + if heads > 1 { + sha = "new" + } + _, _ = w.Write([]byte(`{"head":{"sha":"` + sha + `"}}`)) + return + } + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + if strings.HasSuffix(r.URL.Path, "/old") { + _, _ = w.Write([]byte(`[{"id":1,"context":"build","status":"success"}]`)) + return + } + require.True(t, strings.HasSuffix(r.URL.Path, "/new")) + newSnapshots++ + state := "pending" + if newSnapshots > 1 { + state = "success" + } + _, _ = w.Write([]byte(`[{"id":2,"context":"build","status":"` + state + `"}]`)) + })) + defer server.Close() + service := Pulls{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + items, err := service.WatchChecks(ctx, 7, time.Millisecond, nil) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, "new", items[0].SHA) + require.Equal(t, "success", items[0].State) + require.Equal(t, 2, newSnapshots) + require.Equal(t, 5, heads) +} + +func TestCheckWatchTreatsSkippedAsPassing(t *testing.T) { + snapshots := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/pulls/") { + _, _ = w.Write([]byte(`{"head":{"sha":"abc"}}`)) + return + } + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + snapshots++ + state := "pending" + if snapshots > 1 { + state = "success" + } + _, _ = w.Write([]byte(`[{"id":1,"context":"lint","status":"skipped"},{"id":2,"context":"test","status":"` + state + `"}]`)) + })) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + items, err := (Pulls{Client: client, Repository: "owner/repo"}).WatchChecks(ctx, 7, time.Millisecond, nil) + require.NoError(t, err) + require.Equal(t, 2, snapshots) + require.Equal(t, "success", items[1].State) +} + +func TestCheckFromMapsCommitStatuses(t *testing.T) { + status := decode[gitea.Status](t, `{"id":3,"context":"ci/build","status":"failure","description":"Broken","target_url":"https://ci.test/3", + "url":"https://gitea.test/api/v1/statuses/3","created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-01T12:05:00Z"}`) + require.Equal(t, Check{ + SHA: "abc", ID: 3, Name: "ci/build", State: "failure", Description: "Broken", URL: "https://ci.test/3", + CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-01T12:05:00Z", + }, checkFrom("abc", status)) +} diff --git a/internal/services/comments.go b/internal/services/comments.go new file mode 100644 index 00000000..f7339ef1 --- /dev/null +++ b/internal/services/comments.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Comment is the public representation shared by issue and pull request comments. +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` + URL string `json:"url"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func commentFrom(comment *gitea.Comment) Comment { + return Comment{ + ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster), + CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), + } +} + +// Comments implements discussion comments under their owning repository resource. +type Comments struct { + Client *transport.Client + Repository string +} + +// List returns every discussion comment; the API does not paginate this collection. +func (s Comments) List(ctx context.Context, n int64) ([]Comment, error) { + comments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Comment, *gitea.Response, error) { + return c.Issues.ListIssueComments(ctx, owner, repo, n, gitea.ListIssueCommentOptions{Page: -1}) + }) + if err != nil { + return nil, err + } + return convertAll(comments, commentFrom), nil +} + +// Page applies CLI pagination to the unpaginated comment collection. +func (s Comments) Page(ctx context.Context, n int64, limit, page int) ([]Comment, error) { + items, err := s.List(ctx, n) + if err != nil { + return nil, err + } + return pageOf(items, limit, page) +} + +// View retrieves a comment by its repository-scoped ID. +func (s Comments) View(ctx context.Context, id int64) (Comment, error) { + return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) { + return c.Issues.GetIssueComment(ctx, owner, repo, id) + }) +} + +// Create adds a comment to an issue or pull request. +func (s Comments) Create(ctx context.Context, n int64, body string) (Comment, error) { + return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) { + return c.Issues.CreateIssueComment(ctx, owner, repo, n, gitea.CreateIssueCommentOption{Body: body}) + }) +} + +// Edit replaces the body of a repository comment. +func (s Comments) Edit(ctx context.Context, id int64, body string) (Comment, error) { + return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) { + return c.Issues.EditIssueComment(ctx, owner, repo, id, gitea.EditIssueCommentOption{Body: body}) + }) +} + +// Delete removes a repository comment. +func (s Comments) Delete(ctx context.Context, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Issues.DeleteIssueComment(ctx, owner, repo, id) + }) +} diff --git a/internal/services/comments_test.go b/internal/services/comments_test.go new file mode 100644 index 00000000..d4b3aef2 --- /dev/null +++ b/internal/services/comments_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestCommentFromMapsServerFields(t *testing.T) { + require.Equal(t, Comment{ID: 2, Body: "text", Author: "alice", CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00"}, + commentFrom(decode[gitea.Comment](t, `{"id":2,"body":"text","user":{"login":"alice"},"created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00"}`))) +} + +func TestCommentPaginationIsLocal(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + require.Equal(t, "0", r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`[{"id":1},{"id":2},{"id":3}]`)) + return + } + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, "text", body["body"]) + require.Equal(t, "/api/v1/repos/alice/project/issues/7/comments", r.URL.Path) + _, _ = w.Write([]byte(`{"id":8,"body":"text","html_url":"https://example.test/comment","user":{"login":"alice"}}`)) + })) + service := Comments{Client: client, Repository: "alice/project"} + items, err := service.Page(context.Background(), 7, 2, 2) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, int64(3), items[0].ID) + items, err = service.Page(context.Background(), 7, 2, 3) + require.NoError(t, err) + require.Empty(t, items) + require.NotNil(t, items) + item, err := service.Create(context.Background(), 7, "text") + require.NoError(t, err) + require.Equal(t, "alice", item.Author) + require.Equal(t, "https://example.test/comment", item.URL) +} + +func TestRemoveMilestoneRejectsDifferentMembership(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + mutations++ + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{"milestone":{"id":9}}`)) + })) + service := Issues{Client: client, Repository: "alice/project"} + _, err := service.RemoveMilestone(context.Background(), 7, 8) + require.ErrorContains(t, err, "not assigned to milestone 8") + require.Zero(t, mutations) +} diff --git a/internal/services/convert.go b/internal/services/convert.go new file mode 100644 index 00000000..18fd1715 --- /dev/null +++ b/internal/services/convert.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import gitea "gitea.dev/sdk" + +// convertAll maps SDK items onto tea results as a non-nil slice, skipping absent items. +func convertAll[S, T any](items []*S, convert func(*S) T) []T { + result := make([]T, 0, len(items)) + for _, item := range items { + if item != nil { + result = append(result, convert(item)) + } + } + return result +} + +func labelFrom(label *gitea.Label) Label { + return Label{ID: label.ID, Name: label.Name, Color: label.Color, Description: label.Description, Exclusive: label.Exclusive, Archived: label.IsArchived} +} + +func repositoryFrom(repo *gitea.Repository) Repository { + result := Repository{ + ActionsEnabled: repo.HasActions, Website: repo.Website, ID: repo.ID, Name: repo.Name, + FullName: repo.FullName, Description: repo.Description, URL: repo.HTMLURL, Private: repo.Private, + DefaultBranch: repo.DefaultBranch, CloneURL: repo.CloneURL, SSHURL: repo.SSHURL, + Archived: repo.Archived, Template: repo.Template, Fork: repo.Fork, Mirror: repo.Mirror, OriginalURL: repo.OriginalURL, + } + if repo.Parent != nil { + parent := repositoryFrom(repo.Parent) + result.Parent = &parent + } + return result +} + +func organizationFrom(org *gitea.Organization) Organization { + name := org.Name + if name == "" { + name = org.UserName + } + return Organization{ + ID: org.ID, Name: name, FullName: org.FullName, Description: org.Description, Website: org.Website, + Location: org.Location, Visibility: org.Visibility, RepoAdminChangeTeamAccess: org.RepoAdminChangeTeamAccess, + } +} + +func memberFrom(user *gitea.User) OrganizationMember { + return OrganizationMember{ID: user.ID, Username: user.UserName, FullName: user.FullName} +} + +func issueMilestoneFrom(milestone *gitea.Milestone) *IssueMilestone { + if milestone == nil { + return nil + } + return &IssueMilestone{ID: milestone.ID, Title: milestone.Title} +} + +func issueFrom(issue *gitea.Issue) Issue { + return Issue{ + Author: login(issue.Poster), Labels: convertAll(issue.Labels, labelFrom), Ref: issue.Ref, ID: issue.ID, Number: issue.Index, + Title: issue.Title, Body: issue.Body, URL: issue.HTMLURL, State: string(issue.State), + CreatedAt: timestamp(issue.Created), UpdatedAt: timestamp(issue.Updated), Deadline: optionalTimestamp(issue.Deadline), + Assignees: convertAll(issue.Assignees, login), Milestone: issueMilestoneFrom(issue.Milestone), Comments: int64(issue.Comments), + } +} diff --git a/internal/services/convert_test.go b/internal/services/convert_test.go new file mode 100644 index 00000000..1bd05666 --- /dev/null +++ b/internal/services/convert_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "encoding/json" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func decode[T any](t *testing.T, data string) *T { + t.Helper() + value := new(T) + require.NoError(t, json.Unmarshal([]byte(data), value)) + return value +} + +func TestIssueFromMapsServerFields(t *testing.T) { + issue := decode[gitea.Issue](t, `{ + "id":4,"number":2,"title":"Bug","body":"Details","ref":"main","state":"open","comments":3, + "html_url":"https://gitea.test/alice/project/issues/2","user":{"login":"alice"}, + "labels":[{"id":7,"name":"kind/bug","color":"ee0701","description":"Broken","exclusive":true,"is_archived":true}], + "assignees":[{"login":"bob"},{"login":"carol"}],"milestone":{"id":5,"title":"v1"}, + "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","due_date":"2026-11-01T00:00:00Z"}`) + deadline := "2026-11-01T00:00:00Z" + require.Equal(t, Issue{ + Author: "alice", Labels: []Label{{ID: 7, Name: "kind/bug", Color: "ee0701", Description: "Broken", Exclusive: true, Archived: true}}, + Ref: "main", ID: 4, Number: 2, Title: "Bug", Body: "Details", URL: "https://gitea.test/alice/project/issues/2", State: "open", + CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00", Deadline: &deadline, + Assignees: []string{"bob", "carol"}, Milestone: &IssueMilestone{ID: 5, Title: "v1"}, Comments: 3, + }, issueFrom(issue)) +} + +func TestRepositoryFromMapsParents(t *testing.T) { + repo := decode[gitea.Repository](t, `{ + "id":2,"name":"fork","full_name":"bob/fork","description":"Copy","html_url":"https://gitea.test/bob/fork", + "private":true,"default_branch":"main","clone_url":"https://gitea.test/bob/fork.git","ssh_url":"git@gitea.test:bob/fork.git", + "archived":true,"template":true,"fork":true,"mirror":true,"website":"https://example.test","has_actions":true, + "original_url":"https://source.test/repo","parent":{"id":1,"name":"project","full_name":"alice/project"}}`) + require.Equal(t, Repository{ + ActionsEnabled: true, Website: "https://example.test", ID: 2, Name: "fork", FullName: "bob/fork", Description: "Copy", + URL: "https://gitea.test/bob/fork", Private: true, DefaultBranch: "main", CloneURL: "https://gitea.test/bob/fork.git", + SSHURL: "git@gitea.test:bob/fork.git", Archived: true, Template: true, Fork: true, Mirror: true, + Parent: &Repository{ID: 1, Name: "project", FullName: "alice/project"}, OriginalURL: "https://source.test/repo", + }, repositoryFrom(repo)) +} + +func TestOrganizationAndMemberFrom(t *testing.T) { + org := decode[gitea.Organization](t, `{"id":3,"username":"team","full_name":"Team","description":"About","website":"https://team.test","location":"Earth","visibility":"limited","repo_admin_change_team_access":true}`) + require.Equal(t, Organization{ID: 3, Name: "team", FullName: "Team", Description: "About", Website: "https://team.test", Location: "Earth", Visibility: "limited", RepoAdminChangeTeamAccess: true}, organizationFrom(org)) + org.Name = "renamed" + require.Equal(t, "renamed", organizationFrom(org).Name) + member := decode[gitea.User](t, `{"id":9,"login":"alice","full_name":"Alice","email":"private@example.test"}`) + require.Equal(t, OrganizationMember{ID: 9, Username: "alice", FullName: "Alice"}, memberFrom(member)) +} + +func TestAssetFromUsesBrowserDownloadURL(t *testing.T) { + attachment := decode[gitea.Attachment](t, `{"id":5,"name":"file.bin","size":12,"download_count":2,"created_at":"2026-10-01T12:00:00Z","uuid":"abc","browser_download_url":"https://gitea.test/attachments/abc"}`) + require.Equal(t, ReleaseAsset{ID: 5, Name: "file.bin", Size: 12, URL: "https://gitea.test/attachments/abc", CreatedAt: "2026-10-01T12:00:00Z", DownloadCount: 2}, assetFrom(attachment)) +} + +func TestConvertAllSkipsAbsentItems(t *testing.T) { + require.Equal(t, []string{}, convertAll(nil, login)) + require.Equal(t, []string{"alice"}, convertAll([]*gitea.User{{UserName: "alice"}, nil}, login)) + require.Equal(t, []OrganizationMember{{ID: 1, Username: "alice"}}, convertAll([]*gitea.User{nil, {ID: 1, UserName: "alice"}}, memberFrom)) +} diff --git a/internal/services/draft.go b/internal/services/draft.go new file mode 100644 index 00000000..e7dae427 --- /dev/null +++ b/internal/services/draft.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import "regexp" + +// draftPrefixRe matches the prefixes Gitea recognizes as marking a pull +// request as a draft: a leading "WIP:" or "[WIP]" (case-insensitive), +// followed by any whitespace. +var draftPrefixRe = regexp.MustCompile(`(?i)^(wip:\s*|\[wip\]\s*)`) + +// hasDraftPrefix reports whether title already starts with a draft marker. +func hasDraftPrefix(title string) bool { + return draftPrefixRe.MatchString(title) +} + +// AddDraftPrefix returns title with a "WIP: " prefix, or title unchanged +// if it already carries a recognized draft prefix. +func AddDraftPrefix(title string) string { + if hasDraftPrefix(title) { + return title + } + return "WIP: " + title +} + +// StripDraftPrefix returns title with any recognized leading draft prefix +// removed, or title unchanged if no prefix is present. +func StripDraftPrefix(title string) string { + return draftPrefixRe.ReplaceAllString(title, "") +} diff --git a/internal/services/draft_test.go b/internal/services/draft_test.go new file mode 100644 index 00000000..c0ee06f8 --- /dev/null +++ b/internal/services/draft_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import "testing" + +func TestDraftPrefix(t *testing.T) { + cases := []struct { + in string + has bool + stripped string + withDraft string + }{ + {"plain title", false, "plain title", "WIP: plain title"}, + {"WIP: already", true, "already", "WIP: already"}, + {"wip: lowercase", true, "lowercase", "wip: lowercase"}, + {"[WIP] bracketed", true, "bracketed", "[WIP] bracketed"}, + {"[wip] extra space", true, "extra space", "[wip] extra space"}, + {"Draft: not recognized", false, "Draft: not recognized", "WIP: Draft: not recognized"}, + {"", false, "", "WIP: "}, + } + for _, c := range cases { + if got := hasDraftPrefix(c.in); got != c.has { + t.Errorf("hasDraftPrefix(%q) = %v, want %v", c.in, got, c.has) + } + if got := StripDraftPrefix(c.in); got != c.stripped { + t.Errorf("StripDraftPrefix(%q) = %q, want %q", c.in, got, c.stripped) + } + if got := AddDraftPrefix(c.in); got != c.withDraft { + t.Errorf("AddDraftPrefix(%q) = %q, want %q", c.in, got, c.withDraft) + } + } +} diff --git a/internal/services/errors.go b/internal/services/errors.go new file mode 100644 index 00000000..e137540c --- /dev/null +++ b/internal/services/errors.go @@ -0,0 +1,9 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +// CapabilityError reports an operation absent from the Gitea REST contract. +type CapabilityError struct{ Feature, Reason string } + +func (e *CapabilityError) Error() string { return e.Feature + " is unsupported: " + e.Reason } diff --git a/internal/services/helpers.go b/internal/services/helpers.go new file mode 100644 index 00000000..dbf7916d --- /dev/null +++ b/internal/services/helpers.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" +) + +// maxListPages bounds exhaustive listings so a misbehaving server cannot loop forever. +const maxListPages = 100 + +// pageOf applies CLI pagination to a collection the API returns unpaginated. +func pageOf[T any](items []T, limit, page int) ([]T, error) { + if limit < 1 || page < 1 { + return nil, fmt.Errorf("limit and page must be positive") + } + if page-1 > len(items)/limit { + return []T{}, nil + } + start := (page - 1) * limit + return items[start:min(start+limit, len(items))], nil +} + +// listAll collects pages until the server returns an empty one. +func listAll[T any](fetch func(page int) ([]T, error)) ([]T, error) { + result := []T{} + for page := 1; page <= maxListPages; page++ { + items, err := fetch(page) + if err != nil { + return nil, err + } + if len(items) == 0 { + return result, nil + } + result = append(result, items...) + } + return nil, fmt.Errorf("listing exceeded %d pages", maxListPages) +} + +// pageHints reads whether the server advertises a next page and the collection size. +func pageHints(headers http.Header) (next bool, total int, counted bool) { + links, err := nextPageLinks(headers.Values("Link")) + total, countErr := strconv.Atoi(headers.Get("X-Total-Count")) + return err == nil && len(links) > 0, total, countErr == nil && total >= 0 +} + +// listWindow returns items [(page-1)*limit, page*limit) even when the server caps its page size +// below limit (MAX_RESPONSE_ITEMS), and reports whether the collection continues past them. +func listWindow[T any](limit, page int, fetch func(limit, page int) ([]T, http.Header, error)) ([]T, bool, error) { + items, headers, err := fetch(limit, page) + if err != nil { + return nil, false, err + } + next, total, counted := pageHints(headers) + size := len(items) + if size == 0 || size >= limit { + return items, next || (counted && total > page*limit), nil + } + // A short final page has no next link and accounts for the whole remainder; anything else + // means the server capped the page size. Pages of the returned size are never capped again. + if !next && (!counted || total == (page-1)*limit+size) { + return items, false, nil + } + offset := (page - 1) * limit + result := make([]T, 0, limit+size) + for serverPage := offset/size + 1; ; serverPage++ { + batch := items + if serverPage != page { + if batch, headers, err = fetch(size, serverPage); err != nil { + return nil, false, err + } + } + result = append(result, batch[min(len(batch), max(0, offset-(serverPage-1)*size)):]...) + if len(batch) < size || len(result) >= limit { + break + } + } + next, total, counted = pageHints(headers) + more := len(result) > limit || next || (counted && total > offset+limit) + return result[:min(len(result), limit)], more, nil +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/services/helpers_test.go b/internal/services/helpers_test.go new file mode 100644 index 00000000..15a0f11f --- /dev/null +++ b/internal/services/helpers_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" +) + +// newTestClient serves handler for the duration of the test and returns a client for it. +// Requests probing the server version or API schema fail the test: tea targets one Gitea +// release and must never gate operations on either. +func newTestClient(t *testing.T, handler http.Handler) *transport.Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/api/v1/version") || strings.HasSuffix(r.URL.Path, "/swagger.v1.json") { + t.Errorf("unexpected capability probe %s", r.URL.Path) + w.WriteHeader(http.StatusTeapot) + return + } + handler.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + return transport.New(server.URL, "", false) +} + +// requestJSON decodes the JSON object a handler received. +func requestJSON(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var payload map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + return payload +} diff --git a/internal/services/identity.go b/internal/services/identity.go new file mode 100644 index 00000000..74019e9a --- /dev/null +++ b/internal/services/identity.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "strings" + "sync" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// accountCache holds each client's authenticated login per Sudo user; failed lookups are retried. +var accountCache sync.Map + +type accountKey struct { + client *transport.Client + sudo string +} + +// CurrentAccount verifies the credential against the selected Gitea installation and returns +// the authenticated login. It asks the server once per client and Sudo user. +func CurrentAccount(ctx context.Context, client *transport.Client) (string, error) { + key := accountKey{client, client.Sudo} + if cached, ok := accountCache.Load(key); ok { + return cached.(string), nil + } + user, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetMyUserInfo(ctx) + }) + if err != nil { + return "", err + } + account := login(user) + if strings.TrimSpace(account) == "" { + return "", errors.New("Gitea did not return an authenticated account") + } + accountCache.Store(key, account) + return account, nil +} + +// MatchAccount reports whether selected matches the authenticated login. +// An empty selection accepts any account. +func MatchAccount(selected, authenticated string) bool { + return selected == "" || strings.EqualFold(selected, authenticated) +} diff --git a/internal/services/identity_test.go b/internal/services/identity_test.go new file mode 100644 index 00000000..d8231b88 --- /dev/null +++ b/internal/services/identity_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestCurrentAccountValidatesIdentityAndPropagatesErrors(t *testing.T) { + for _, tc := range []struct { + body string + status int + want string + }{ + {`{"login":"Alice"}`, 200, "Alice"}, + {`{"login":" "}`, 200, ""}, + {`invalid`, 200, ""}, + {`{"message":"do not expose this"}`, 401, ""}, + } { + t.Run(tc.body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/base/api/v1/user", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "token secret", r.Header.Get("Authorization")) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + account, err := CurrentAccount(context.Background(), transport.New(server.URL+"/base", "secret", false)) + if tc.want != "" { + require.NoError(t, err) + require.Equal(t, tc.want, account) + } else { + require.Error(t, err) + require.Empty(t, account) + require.NotContains(t, err.Error(), "secret") + require.NotContains(t, err.Error(), "do not expose this") + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := CurrentAccount(ctx, transport.New("https://example.org", "secret", false)) + require.True(t, errors.Is(err, context.Canceled)) +} + +func TestCurrentAccountAsksOncePerClientAndSudoUser(t *testing.T) { + requests := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + login := "alice" + if sudo := r.Header.Get("Sudo"); sudo != "" { + login = sudo + } + _, _ = w.Write([]byte(`{"login":"` + login + `"}`)) + })) + for range 2 { + account, err := CurrentAccount(context.Background(), client) + require.NoError(t, err) + require.Equal(t, "alice", account) + } + client.Sudo = "bob" + account, err := CurrentAccount(context.Background(), client) + require.NoError(t, err) + require.Equal(t, "bob", account) + require.Equal(t, 2, requests) +} + +func TestMatchAccount(t *testing.T) { + require.True(t, MatchAccount("", "Alice")) + require.True(t, MatchAccount("alice", "Alice")) + require.False(t, MatchAccount("bob", "Alice")) +} diff --git a/internal/services/issue_attachments.go b/internal/services/issue_attachments.go new file mode 100644 index 00000000..674c1550 --- /dev/null +++ b/internal/services/issue_attachments.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "io" + + gitea "gitea.dev/sdk" +) + +// Attachments paginates the server's unpaginated attachment collection locally. +func (s Issues) Attachments(ctx context.Context, number int64, limit, page int) ([]IssueAttachment, error) { + attachments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Attachment, *gitea.Response, error) { + return c.Issues.ListIssueAttachments(ctx, owner, repo, number) + }) + if err != nil { + return nil, err + } + items, err := pageOf(attachments, limit, page) + if err != nil { + return nil, err + } + return convertAll(items, func(item *gitea.Attachment) IssueAttachment { return IssueAttachment(assetFrom(item)) }), nil +} + +// Attachment retrieves metadata for one attachment in its owning issue. +func (s Issues) Attachment(ctx context.Context, number, id int64) (IssueAttachment, error) { + attachment, err := s.attachment(ctx, number, id) + if err != nil { + return IssueAttachment{}, err + } + return IssueAttachment(assetFrom(attachment)), nil +} + +func (s Issues) attachment(ctx context.Context, number, id int64) (*gitea.Attachment, error) { + return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Attachment, *gitea.Response, error) { + return c.Issues.GetIssueAttachment(ctx, owner, repo, number, id) + }) +} + +// RenameAttachment changes the filename without replacing its content. +func (s Issues) RenameAttachment(ctx context.Context, number, id int64, name string) (IssueAttachment, error) { + attachment, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Attachment, *gitea.Response, error) { + return c.Issues.EditIssueAttachment(ctx, owner, repo, number, id, gitea.EditAttachmentOptions{Name: name}) + }) + if err != nil { + return IssueAttachment{}, err + } + return IssueAttachment(assetFrom(attachment)), nil +} + +// DeleteAttachment removes one attachment from its owning issue. +func (s Issues) DeleteAttachment(ctx context.Context, number, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Issues.DeleteIssueAttachment(ctx, owner, repo, number, id) + }) +} + +// UploadAttachment streams a file to its owning issue or pull request. +func (s Issues) UploadAttachment(ctx context.Context, number int64, name string, input io.Reader) (IssueAttachment, error) { + asset, err := uploadAttachment(ctx, s.Client, fmt.Sprintf("%s/%d/assets", s.endpoint(), number), name, input) + return IssueAttachment(asset), err +} + +// DownloadAttachment opens an attachment stream scoped to its instance and issue. +func (s Issues) DownloadAttachment(ctx context.Context, number, id int64) (io.ReadCloser, error) { + attachment, err := s.attachment(ctx, number, id) + if err != nil { + return nil, err + } + return downloadAttachment(ctx, s.Client, attachment) +} diff --git a/internal/services/issue_create_selectables.go b/internal/services/issue_create_selectables.go new file mode 100644 index 00000000..3cd95869 --- /dev/null +++ b/internal/services/issue_create_selectables.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" +) + +// IssueTemplate is a repository issue template used to seed create forms. +type IssueTemplate struct { + Name, About, Title, Content string + Form bool +} + +type issueTemplateResponse struct { + Name string `json:"name"` + About string `json:"about"` + Title string `json:"title"` + MarkdownContent string `json:"content"` + Form []any `json:"body"` +} + +// IssueTemplates lists repository issue templates; null responses become an empty list. +func (s Issues) IssueTemplates(ctx context.Context) ([]IssueTemplate, error) { + var response []issueTemplateResponse + // TODO(sdk): IssueFormElementAttributes.Options is []string, so GetIssueTemplates fails on checkbox options, which are objects. + if err := getJSON(ctx, s.Client, "repos/"+s.Repository+"/issue_templates", &response); err != nil { + return nil, err + } + result := make([]IssueTemplate, 0, len(response)) + for _, r := range response { + result = append(result, IssueTemplate{Name: r.Name, About: r.About, Title: r.Title, Content: r.MarkdownContent, Form: r.Form != nil}) + } + return result, nil +} + +// IssueCreateSelectables adds repository issue templates to the create form choices. +type IssueCreateSelectables struct { + CreateSelectables + Templates []IssueTemplate +} + +// CreateSelectables loads templates and, when the user can push, assignees/milestones/labels. +func (s Issues) CreateSelectables(ctx context.Context) (IssueCreateSelectables, error) { + choices, err := loadCreateSelectables(ctx, s.Client, s.Repository) + if err != nil { + return IssueCreateSelectables{}, err + } + templates, err := s.IssueTemplates(ctx) + return IssueCreateSelectables{CreateSelectables: choices, Templates: templates}, err +} diff --git a/internal/services/issue_create_selectables_test.go b/internal/services/issue_create_selectables_test.go new file mode 100644 index 00000000..058f329b --- /dev/null +++ b/internal/services/issue_create_selectables_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryAssigneesParseLogins(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/assignees", r.URL.Path) + _, _ = w.Write([]byte(`[{"login":"alice","email":"hidden@example.test"},{"login":"bob"}]`)) + })) + names, err := (Repositories{Client: client}).assignees(context.Background(), "alice/project") + require.NoError(t, err) + require.Equal(t, []string{"alice", "bob"}, names) +} + +func TestIssueTemplatesParseMarkdownAndForm(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/issue_templates", r.URL.Path) + _, _ = w.Write([]byte(`[ + {"name":"Bug","about":"Report a bug","title":"bug: ","content":"## Steps\n","body":null}, + {"name":"Form","about":"Structured","title":"form: ","content":"","body":[{"type":"markdown","attributes":{"value":"Hi"}}]} + ]`)) + })) + templates, err := (Issues{Client: client, Repository: "alice/project"}).IssueTemplates(context.Background()) + require.NoError(t, err) + require.Equal(t, []IssueTemplate{ + {Name: "Bug", About: "Report a bug", Title: "bug: ", Content: "## Steps\n", Form: false}, + {Name: "Form", About: "Structured", Title: "form: ", Content: "", Form: true}, + }, templates) +} + +func TestIssueTemplatesNullBecomesEmpty(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`null`)) + })) + templates, err := (Issues{Client: client, Repository: "alice/project"}).IssueTemplates(context.Background()) + require.NoError(t, err) + require.Empty(t, templates) + require.NotNil(t, templates) +} + +func TestCreateSelectablesLoadWriteFieldsOnlyWithPush(t *testing.T) { + pages := map[string]map[string]string{ + "/api/v1/repos/alice/project/milestones": {"1": `[{"id":1,"title":"v1"}]`, "2": `[{"id":2,"title":"v2"}]`}, + "/api/v1/repos/alice/project/labels": {"1": `[{"id":3,"name":"bug"}]`, "2": `[{"id":4,"name":"docs"}]`}, + } + for _, push := range []bool{false, true} { + t.Run(fmt.Sprintf("push=%t", push), func(t *testing.T) { + calls := map[string]int{} + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls[r.URL.Path]++ + switch r.URL.Path { + case "/api/v1/repos/alice/project": + _, _ = fmt.Fprintf(w, `{"full_name":"alice/project","permissions":{"push":%t,"pull":true}}`, push) + case "/api/v1/repos/alice/project/issue_templates": + _, _ = w.Write([]byte(`[{"name":"Bug","title":"t","content":"c"}]`)) + case "/api/v1/repos/alice/project/assignees": + _, _ = w.Write([]byte(`[{"login":"alice"},{"login":"bob"}]`)) + case "/api/v1/repos/alice/project/milestones", "/api/v1/repos/alice/project/labels": + if strings.HasSuffix(r.URL.Path, "/milestones") { + assert.Equal(t, "open", r.URL.Query().Get("state")) + } + body, ok := pages[r.URL.Path][r.URL.Query().Get("page")] + if !ok { + body = `[]` + } + _, _ = w.Write([]byte(body)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + for _, load := range []func() (CreateSelectables, error){ + func() (CreateSelectables, error) { + return (Pulls{Client: client, Repository: "alice/project"}).CreateSelectables(context.Background()) + }, + func() (CreateSelectables, error) { + result, err := (Issues{Client: client, Repository: "alice/project"}).CreateSelectables(context.Background()) + require.Len(t, result.Templates, 1) + return result.CreateSelectables, err + }, + } { + result, err := load() + require.NoError(t, err) + want := CreateSelectables{Assignees: []string{}, Milestones: []Milestone{}, Labels: []Label{}} + if push { + want = CreateSelectables{ + CanPush: true, + Assignees: []string{"alice", "bob"}, + Milestones: []Milestone{{ID: 1, Title: "v1"}, {ID: 2, Title: "v2"}}, + Labels: []Label{{ID: 3, Name: "bug"}, {ID: 4, Name: "docs"}}, + } + } + require.Equal(t, want, result) + } + require.Equal(t, 2, calls["/api/v1/repos/alice/project"]) + require.Equal(t, 1, calls["/api/v1/repos/alice/project/issue_templates"]) + if !push { + require.Zero(t, calls["/api/v1/repos/alice/project/assignees"]) + } + }) + } +} + +func TestViewPermissionsKeepsRepositorySchemaSeparate(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":1,"name":"project","full_name":"alice/project","permissions":{"admin":true,"push":true,"pull":true}}`)) + })) + repo, err := (Repositories{Client: client}).View(context.Background(), "alice/project") + require.NoError(t, err) + require.Equal(t, "alice/project", repo.FullName) + permissions, err := (Repositories{Client: client}).viewPermissions(context.Background(), "alice/project") + require.NoError(t, err) + require.Equal(t, RepositoryPermissions{Admin: true, Push: true, Pull: true}, permissions) +} diff --git a/internal/services/issue_dependencies.go b/internal/services/issue_dependencies.go new file mode 100644 index 00000000..5e3f292e --- /dev/null +++ b/internal/services/issue_dependencies.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "net/http" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// IssueReference identifies an issue that may live in another repository. +// Number is zero for issues the account cannot read. +type IssueReference struct { + Repository string + Number int64 +} + +// IssueDependencies lists the issues blocking one issue and the issues it blocks. +type IssueDependencies struct { + BlockedBy, Blocks []IssueReference +} + +func issueReferenceFrom(issue *gitea.Issue) IssueReference { + reference := IssueReference{Number: issue.Index} + if issue.Repository != nil { + reference.Repository = issue.Repository.FullName + } + return reference +} + +// Dependencies reads both dependency directions; servers or repositories without +// dependency support report none. +func (s Issues) Dependencies(ctx context.Context, number int64) (IssueDependencies, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return IssueDependencies{}, err + } + result := IssueDependencies{} + for _, list := range []struct { + fetch func(ctx context.Context, c *gitea.Client, opts gitea.ListOptions) ([]*gitea.Issue, *gitea.Response, error) + dest *[]IssueReference + }{ + {func(ctx context.Context, c *gitea.Client, opts gitea.ListOptions) ([]*gitea.Issue, *gitea.Response, error) { + return c.Issues.ListIssueDependencies(ctx, owner, repo, number, gitea.ListIssueDependenciesOptions{ListOptions: opts}) + }, &result.BlockedBy}, + {func(ctx context.Context, c *gitea.Client, opts gitea.ListOptions) ([]*gitea.Issue, *gitea.Response, error) { + return c.Issues.ListIssueBlocks(ctx, owner, repo, number, gitea.ListIssueBlocksOptions{ListOptions: opts}) + }, &result.Blocks}, + } { + items, err := listAll(func(page int) ([]IssueReference, error) { + issues, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { + return list.fetch(ctx, c, listOptions(50, page)) + }) + return convertAll(issues, issueReferenceFrom), err + }) + if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound { + return IssueDependencies{}, nil + } + if err != nil { + return IssueDependencies{}, err + } + *list.dest = items + } + return result, nil +} diff --git a/internal/services/issue_labels_test.go b/internal/services/issue_labels_test.go new file mode 100644 index 00000000..dad51340 --- /dev/null +++ b/internal/services/issue_labels_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLabelReplacementClearsAndReportsPartialFailure(t *testing.T) { + calls := []string{} + fail := false + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch r.Method { + case http.MethodPut: + var body struct { + Labels []int64 `json:"labels"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Labels) + require.Empty(t, body.Labels) + _, _ = w.Write([]byte(`[]`)) + case http.MethodPatch: + if fail { + w.WriteHeader(http.StatusForbidden) + return + } + var body gitea.EditIssueOption + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, gitea.EditIssueOption{Title: "Updated"}, body) + _, _ = w.Write([]byte(`{"number":7,"title":"Updated"}`)) + default: + _, _ = w.Write([]byte(`{"number":7}`)) + } + })) + service := Issues{Client: client, Repository: "alice/project"} + ids := []int64{} + _, err := service.Edit(context.Background(), 7, IssueEdit{Labels: &ids}) + require.NoError(t, err) + require.Equal(t, []string{"PUT /api/v1/repos/alice/project/issues/7/labels", "GET /api/v1/repos/alice/project/issues/7"}, calls) + calls = nil + title := "Updated" + _, err = service.Edit(context.Background(), 7, IssueEdit{Labels: &ids, Title: &title}) + require.NoError(t, err) + require.Len(t, calls, 2) + fail = true + _, err = service.Edit(context.Background(), 7, IssueEdit{Labels: &ids, Title: &title}) + require.ErrorContains(t, err, "labels were updated") +} + +func TestPullEditMergesChangesWithCurrentState(t *testing.T) { + var calls []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + body, _ := io.ReadAll(r.Body) + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/issues/7": + _, _ = w.Write([]byte(`{"number":7,"labels":[{"id":1},{"id":2}],"assignees":[{"login":"alice"},{"login":"bob"}]}`)) + case "PUT /api/v1/repos/alice/project/issues/7/labels": + assert.JSONEq(t, `{"labels":[1,3]}`, string(body)) + _, _ = w.Write([]byte(`[]`)) + case "PATCH /api/v1/repos/alice/project/issues/7": + assertIssueEdit(t, gitea.EditIssueOption{Assignees: []string{"alice", "carol"}}, body) + _, _ = w.Write([]byte(`{"number":7}`)) + case "GET /api/v1/repos/alice/project/pulls/7": + _, _ = w.Write([]byte(`{"number":7}`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + changes := MetadataChanges{AddLabels: []int64{3, 1}, RemoveLabels: []int64{2}, AddAssignees: []string{"carol", "Carol", "ALICE"}, RemoveAssignees: []string{"BOB"}} + _, err := (Pulls{Client: client, Repository: "alice/project"}).Edit(context.Background(), 7, PullEdit{Changes: &changes}) + require.NoError(t, err) + require.Equal(t, []string{ + "GET /api/v1/repos/alice/project/issues/7", "PUT /api/v1/repos/alice/project/issues/7/labels", + "PATCH /api/v1/repos/alice/project/issues/7", "GET /api/v1/repos/alice/project/pulls/7", + }, calls) +} + +func TestIssueEditDeduplicatesAssigneesIgnoringCase(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + assert.Equal(t, "PATCH /api/v1/repos/alice/project/issues/7", r.Method+" "+r.URL.Path) + assertIssueEdit(t, gitea.EditIssueOption{Assignees: []string{"alice", "bob"}}, body) + _, _ = w.Write([]byte(`{"number":7}`)) + })) + assignees := []string{"alice", "Alice", "bob"} + _, err := (Issues{Client: client, Repository: "alice/project"}).Edit(context.Background(), 7, IssueEdit{Assignees: &assignees}) + require.NoError(t, err) +} + +func assertIssueEdit(t *testing.T, want gitea.EditIssueOption, body []byte) { + t.Helper() + var got gitea.EditIssueOption + if assert.NoError(t, json.Unmarshal(body, &got)) { + assert.Equal(t, want, got) + } +} diff --git a/internal/services/issues.go b/internal/services/issues.go new file mode 100644 index 00000000..51126068 --- /dev/null +++ b/internal/services/issues.go @@ -0,0 +1,140 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Issue is the stable command representation of an issue. +type Issue struct { + Author string `json:"author"` + Labels []Label `json:"labels"` + Ref string `json:"ref"` + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Deadline *string `json:"due_date"` + Assignees []string `json:"assignees"` + Milestone *IssueMilestone `json:"milestone"` + Comments int64 `json:"comments"` +} + +// IssueMilestone identifies the milestone associated with an issue or pull request. +type IssueMilestone struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +// IssueOptions supplies issue creation input. +type IssueOptions struct { + Ref string `json:"ref,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + Assignees []string `json:"assignees,omitempty"` + Labels []int64 `json:"labels,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Deadline *time.Time `json:"due_date,omitempty"` +} + +// ListOptions supplies explicit issue pagination and filters. +type ListOptions struct { + State string + Limit, Page int + Search string + Assignee, Labels, Milestones, Kind string + Author, Mentions string + Since, Before *time.Time +} + +// Issues implements repository issue operations. +type Issues struct { + Client *transport.Client + Repository string +} + +func (s Issues) endpoint() string { return "repos/" + s.Repository + "/issues" } + +// List returns a page of issues, even when the server caps its page size below the limit, +// and reports whether more issues follow. +func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, false, err + } + kind := "issues" + if opts.Kind != "" { + kind = opts.Kind + } + query := gitea.ListIssueOption{ + State: gitea.StateType(opts.State), Type: gitea.IssueType(kind), KeyWord: opts.Search, + Labels: filterValues(opts.Labels), Milestones: filterValues(opts.Milestones), + CreatedBy: opts.Author, AssignedBy: opts.Assignee, MentionedBy: opts.Mentions, + } + if opts.Since != nil { + query.Since = *opts.Since + } + if opts.Before != nil { + query.Before = *opts.Before + } + issues, more, err := listWindow(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Issue, http.Header, error) { + query.ListOptions = listOptions(limit, page) + issues, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { + return c.Issues.ListRepoIssues(ctx, owner, repo, query) + }) + return issues, headerOf(resp), err + }) + if err != nil { + return nil, false, err + } + return convertAll(issues, issueFrom), more, nil +} + +// filterValues splits a comma-separated filter, which the SDK joins again. +func filterValues(value string) []string { + if value == "" { + return nil + } + return strings.Split(value, ",") +} + +// View retrieves one issue. +func (s Issues) View(ctx context.Context, number int64) (Issue, error) { + return repoConvert(ctx, s.Client, s.Repository, issueFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) { + return c.Issues.GetIssue(ctx, owner, repo, number) + }) +} + +// Create opens an issue. +func (s Issues) Create(ctx context.Context, options IssueOptions) (Issue, error) { + return repoConvert(ctx, s.Client, s.Repository, issueFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) { + return c.Issues.CreateIssue(ctx, owner, repo, gitea.CreateIssueOption{ + Title: options.Title, Body: options.Body, Ref: options.Ref, Assignees: options.Assignees, + Deadline: options.Deadline, Milestone: options.Milestone, Labels: options.Labels, + }) + }) +} + +// SetState updates only the selected issue state. +func (s Issues) SetState(ctx context.Context, number int64, state string) (Issue, error) { + value := gitea.StateType(state) + return s.edit(ctx, number, gitea.EditIssueOption{State: &value}) +} + +func (s Issues) edit(ctx context.Context, number int64, opts gitea.EditIssueOption) (Issue, error) { + return repoConvert(ctx, s.Client, s.Repository, issueFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) { + return c.Issues.EditIssue(ctx, owner, repo, number, opts) + }) +} diff --git a/internal/services/issues_test.go b/internal/services/issues_test.go new file mode 100644 index 00000000..34e50ddf --- /dev/null +++ b/internal/services/issues_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssueListSendsTypePageSizeAndFilters(t *testing.T) { + var queries []url.Values + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/issues", r.URL.Path) + queries = append(queries, r.URL.Query()) + _, _ = w.Write([]byte(`[{"id":1,"number":2,"title":"Bug","user":{"login":"bob"}}]`)) + })) + service := Issues{Client: client, Repository: "alice/project"} + items, more, err := service.List(context.Background(), ListOptions{State: "open", Limit: 1, Page: 3}) + require.NoError(t, err) + require.False(t, more) + require.Equal(t, []Issue{{Author: "bob", ID: 1, Number: 2, Title: "Bug", Labels: []Label{}, Assignees: []string{}}}, items) + since := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + _, _, err = service.List(context.Background(), ListOptions{ + Kind: "all", Limit: 30, Page: 1, Search: "crash", Assignee: "carol", Labels: "bug,ui", Milestones: "v1", + Author: "dave", Mentions: "erin", Since: &since, + }) + require.NoError(t, err) + require.Equal(t, []url.Values{ + {"state": {"open"}, "type": {"issues"}, "limit": {"1"}, "page": {"3"}}, + { + "type": {"all"}, "limit": {"30"}, "page": {"1"}, "q": {"crash"}, "assigned_by": {"carol"}, "labels": {"bug,ui"}, + "milestones": {"v1"}, "created_by": {"dave"}, "mentioned_by": {"erin"}, "since": {"2026-10-01T08:00:00Z"}, + }, + }, queries) +} + +func TestIssueDependenciesMapReferencesAndTreatMissingSupportAsNone(t *testing.T) { + missing := false + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if missing { + w.WriteHeader(http.StatusNotFound) + return + } + assert.Equal(t, "50", r.URL.Query().Get("limit")) + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + switch r.URL.Path { + case "/api/v1/repos/alice/project/issues/7/dependencies": + _, _ = w.Write([]byte(`[{"number":3,"repository":{"full_name":"alice/other"}},{"number":0,"repository":null}]`)) + case "/api/v1/repos/alice/project/issues/7/blocks": + _, _ = w.Write([]byte(`[{"number":9,"repository":{"full_name":"alice/project"}}]`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + service := Issues{Client: client, Repository: "alice/project"} + dependencies, err := service.Dependencies(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, IssueDependencies{ + BlockedBy: []IssueReference{{Repository: "alice/other", Number: 3}, {}}, + Blocks: []IssueReference{{Repository: "alice/project", Number: 9}}, + }, dependencies) + missing = true + dependencies, err = service.Dependencies(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, IssueDependencies{}, dependencies) +} + +func TestIssueTemplatesAcceptCheckboxOptionObjects(t *testing.T) { + body := `[{"name":"Form","body":[{"type":"checkboxes","attributes":{"options":[{"label":"I searched","required":true}]}}]}]` + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + templates, err := (Issues{Client: client, Repository: "alice/project"}).IssueTemplates(context.Background()) + require.NoError(t, err) + require.Equal(t, []IssueTemplate{{Name: "Form", Form: true}}, templates) + var sdkTemplates []*gitea.IssueTemplate + require.Error(t, json.Unmarshal([]byte(body), &sdkTemplates), "remove the raw request once the SDK decodes checkbox options") +} diff --git a/internal/services/label_selection.go b/internal/services/label_selection.go new file mode 100644 index 00000000..e1082ace --- /dev/null +++ b/internal/services/label_selection.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "strings" +) + +func selectLabelNames(names []string, matches map[string][]int64) ([]int64, error) { + ids := make([]int64, 0, len(names)) + seen := map[int64]bool{} + for _, name := range names { + candidates := matches[name] + if len(candidates) == 0 { + return nil, fmt.Errorf("label %q does not exist; use tea label list", name) + } + if len(candidates) > 1 { + return nil, fmt.Errorf("label %q is ambiguous", name) + } + if !seen[candidates[0]] { + ids = append(ids, candidates[0]) + seen[candidates[0]] = true + } + } + return ids, nil +} + +// ResolveIssueNames includes labels shared by the repository's organization. +// Duplicate names require an explicit scope to avoid choosing a different label. +func (s Labels) ResolveIssueNames(ctx context.Context, names []string) ([]int64, error) { + if len(names) == 0 { + return []int64{}, nil + } + for _, name := range names { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("label names cannot be empty") + } + } + scopes := []Labels{s} + owner, _, _ := strings.Cut(s.Repository, "/") + _, isOrg, err := organization(ctx, s.Client, owner) + if err != nil { + return nil, err + } + if isOrg { + scopes = append(scopes, Labels{Client: s.Client, Organization: owner}) + } + type labelKey struct{ scope, name string } + index := map[labelKey][]int64{} + for _, scope := range scopes { + scopeName := "repo" + if scope.Organization != "" { + scopeName = "org" + } + labels, err := scope.All(ctx) + if err != nil { + return nil, err + } + for _, label := range labels { + for _, key := range []labelKey{{scopeName, label.Name}, {"", label.Name}} { + index[key] = append(index[key], label.ID) + } + } + } + matches := map[string][]int64{} + for _, input := range names { + scope, name := splitLabelScope(input) + matches[input] = index[labelKey{scope, name}] + } + return selectLabelNames(names, matches) +} + +func splitLabelScope(input string) (scope, name string) { + for _, prefix := range []string{"repo:", "org:"} { + if after, ok := strings.CutPrefix(input, prefix); ok { + return strings.TrimSuffix(prefix, ":"), after + } + } + return "", input +} + +// RequireFilterNames rejects names Gitea's repository issue filter cannot match: it +// matches repository labels by exact name and silently ignores every other name. +func (s Labels) RequireFilterNames(ctx context.Context, names []string) error { + labels, err := s.All(ctx) + if err != nil { + return err + } + known := make(map[string]bool, len(labels)) + for _, label := range labels { + known[label.Name] = true + } + for _, name := range names { + if !known[name] { + return fmt.Errorf("label %q does not exist in %s; issue filters match repository labels only, see tea label list", name, s.Repository) + } + } + return nil +} diff --git a/internal/services/label_selection_test.go b/internal/services/label_selection_test.go new file mode 100644 index 00000000..8135c9fd --- /dev/null +++ b/internal/services/label_selection_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLabelSelectionReadsCappedPagesAndPreservesOrder(t *testing.T) { + reads := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + if r.URL.Path == "/api/v1/orgs/alice" { + w.WriteHeader(http.StatusNotFound) + return + } + reads++ + require.Equal(t, "/api/v1/repos/alice/project/labels", r.URL.Path) + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`[{"id":1,"name":"bug"}]`)) + case "2": + _, _ = w.Write([]byte(`[{"id":2,"name":"needs review"}]`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + service := Labels{Client: client, Repository: "alice/project"} + ids, err := service.ResolveIssueNames(context.Background(), []string{"needs review", "bug", "bug"}) + require.NoError(t, err) + require.Equal(t, []int64{2, 1}, ids) + require.Equal(t, 3, reads) + _, err = service.ResolveIssueNames(context.Background(), []string{"missing"}) + require.ErrorContains(t, err, "does not exist") + before := reads + _, err = service.ResolveIssueNames(context.Background(), []string{" "}) + require.ErrorContains(t, err, "cannot be empty") + require.Equal(t, before, reads) +} + +func TestIssueLabelsCombineScopesWithoutSilentlyChoosingDuplicates(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + if r.URL.Path == "/api/v1/orgs/company" { + _, _ = w.Write([]byte(`{"id":9}`)) + return + } + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + switch r.URL.Path { + case "/api/v1/repos/company/project/labels": + _, _ = w.Write([]byte(`[{"id":1,"name":"bug"}]`)) + case "/api/v1/orgs/company/labels": + _, _ = w.Write([]byte(`[{"id":2,"name":"bug"},{"id":3,"name":"shared"}]`)) + default: + t.Errorf("unexpected endpoint %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + service := Labels{Client: client, Repository: "company/project"} + _, err := service.ResolveIssueNames(context.Background(), []string{"bug"}) + require.ErrorContains(t, err, "ambiguous") + ids, err := service.ResolveIssueNames(context.Background(), []string{"org:bug", "repo:bug", "shared", "shared"}) + require.NoError(t, err) + require.Equal(t, []int64{2, 1, 3}, ids) +} + +func TestScopedLabelNamesDoNotCollideWithLiteralPrefixes(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/orgs/alice" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.URL.Query().Get("page") == "1" { + _, _ = w.Write([]byte(`[{"id":1,"name":"bug"},{"id":2,"name":"repo:bug"},{"id":3,"name":"org:bug"}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + service := Labels{Client: client, Repository: "alice/project"} + ids, err := service.ResolveIssueNames(context.Background(), []string{"repo:bug", "repo:repo:bug", "repo:org:bug", "repo:bug"}) + require.NoError(t, err) + require.Equal(t, []int64{1, 2, 3}, ids) + _, err = service.ResolveIssueNames(context.Background(), []string{"org:bug"}) + require.ErrorContains(t, err, "does not exist") +} diff --git a/internal/services/metadata.go b/internal/services/metadata.go new file mode 100644 index 00000000..28e8ebf1 --- /dev/null +++ b/internal/services/metadata.go @@ -0,0 +1,388 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Label is a repository or organization label. +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + Exclusive bool `json:"exclusive"` + Archived bool `json:"is_archived"` +} + +// LabelOptions distinguishes omitted label fields during edits. +type LabelOptions struct { + Name *string `json:"name,omitempty"` + Color *string `json:"color,omitempty"` + Description *string `json:"description,omitempty"` + Exclusive *bool `json:"exclusive,omitempty"` + Archived *bool `json:"is_archived,omitempty"` +} + +// Labels implements explicitly scoped label operations. +type Labels struct { + Client *transport.Client + Repository, Organization string +} + +func (s Labels) orgEndpoint() string { return "orgs/" + url.PathEscape(s.Organization) + "/labels" } + +// List returns scoped labels without merging unrelated organization labels. +func (s Labels) List(ctx context.Context, limit, page int) ([]Label, error) { + labels, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Label, *gitea.Response, error) { + if s.Organization != "" { + return c.Organizations.ListOrgLabels(ctx, s.Organization, gitea.ListOrgLabelsOptions{ListOptions: listOptions(limit, page)}) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(labels, labelFrom), nil +} + +// All returns every label in the scope. +func (s Labels) All(ctx context.Context) ([]Label, error) { + return listAll(func(page int) ([]Label, error) { return s.List(ctx, 100, page) }) +} + +// Create creates one label. +func (s Labels) Create(ctx context.Context, opts LabelOptions) (Label, error) { + if s.Organization != "" && opts.Archived != nil && *opts.Archived { + // TODO(sdk): CreateOrgLabelOption.IsArchived + var result Label + err := sendJSON(ctx, s.Client, http.MethodPost, s.orgEndpoint(), opts, &result) + return result, err + } + name, color, description := deref(opts.Name), deref(opts.Color), deref(opts.Description) + exclusive, archived := deref(opts.Exclusive), deref(opts.Archived) + label, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Label, *gitea.Response, error) { + if s.Organization != "" { + return c.Organizations.CreateOrgLabel(ctx, s.Organization, gitea.CreateOrgLabelOption{ + Name: name, Color: color, Description: description, Exclusive: exclusive, + }) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Repositories.CreateLabel(ctx, owner, repo, gitea.CreateLabelOption{ + Name: name, Color: color, Description: description, Exclusive: exclusive, IsArchived: archived, + }) + }) + if err != nil { + return Label{}, err + } + return labelFrom(label), nil +} + +// Edit updates only explicitly supplied fields. +func (s Labels) Edit(ctx context.Context, id int64, opts LabelOptions) (Label, error) { + if s.Organization != "" && opts.Archived != nil { + // TODO(sdk): EditOrgLabelOption.IsArchived + var result Label + err := sendJSON(ctx, s.Client, http.MethodPatch, fmt.Sprintf("%s/%d", s.orgEndpoint(), id), opts, &result) + return result, err + } + label, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Label, *gitea.Response, error) { + if s.Organization != "" { + return c.Organizations.EditOrgLabel(ctx, s.Organization, id, gitea.EditOrgLabelOption{ + Name: opts.Name, Color: opts.Color, Description: opts.Description, Exclusive: opts.Exclusive, + }) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Repositories.EditLabel(ctx, owner, repo, id, gitea.EditLabelOption{ + Name: opts.Name, Color: opts.Color, Description: opts.Description, Exclusive: opts.Exclusive, IsArchived: opts.Archived, + }) + }) + if err != nil { + return Label{}, err + } + return labelFrom(label), nil +} + +// Delete deletes one scoped label. +func (s Labels) Delete(ctx context.Context, id int64) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + if s.Organization != "" { + return c.Organizations.DeleteOrgLabel(ctx, s.Organization, id) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, err + } + return c.Repositories.DeleteLabel(ctx, owner, repo, id) + }) +} + +// Milestone is a stable milestone result. +type Milestone struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Deadline *string `json:"due_on"` + OpenIssues int `json:"open_issues"` + ClosedIssues int `json:"closed_issues"` +} + +func milestoneFrom(milestone *gitea.Milestone) Milestone { + return Milestone{ + ID: milestone.ID, Title: milestone.Title, Description: milestone.Description, State: string(milestone.State), + Deadline: optionalTimestamp(milestone.Deadline), OpenIssues: milestone.OpenIssues, ClosedIssues: milestone.ClosedIssues, + } +} + +// MilestoneOptions allows explicit state, description and deadline updates. +type MilestoneOptions struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + State *string `json:"state,omitempty"` + Deadline *string `json:"due_on,omitempty"` +} + +// deadline parses the RFC 3339 deadline; the zero time clears it. +func (o MilestoneOptions) deadline() (*time.Time, error) { + if o.Deadline == nil { + return nil, nil + } + deadline, err := time.Parse(time.RFC3339, *o.Deadline) + if err != nil { + return nil, fmt.Errorf("invalid milestone deadline %q: %w", *o.Deadline, err) + } + return &deadline, nil +} + +// Milestones implements repository milestone operations. +type Milestones struct { + Client *transport.Client + Repository string +} + +// List returns a non-nil page of milestones. +func (s Milestones) List(ctx context.Context, state string, limit, page int) ([]Milestone, error) { + milestones, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Milestone, *gitea.Response, error) { + return c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{ListOptions: listOptions(limit, page), State: gitea.StateType(state)}) + }) + if err != nil { + return nil, err + } + return convertAll(milestones, milestoneFrom), nil +} + +// Create creates one milestone. +func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return Milestone{}, err + } + deadline, err := opts.deadline() + if err != nil { + return Milestone{}, err + } + milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) { + return c.Repositories.CreateMilestone(ctx, owner, repo, gitea.CreateMilestoneOption{ + Title: deref(opts.Title), Description: deref(opts.Description), State: gitea.StateType(deref(opts.State)), Deadline: deadline, + }) + }) + if err != nil { + return Milestone{}, err + } + return milestoneFrom(milestone), nil +} + +// Edit updates a milestone. +func (s Milestones) Edit(ctx context.Context, id int64, opts MilestoneOptions) (Milestone, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return Milestone{}, err + } + deadline, err := opts.deadline() + if err != nil { + return Milestone{}, err + } + // Gitea clears omitted due_on values, so unrelated edits must resend the date. + if opts.Deadline == nil { + current, err := s.get(ctx, owner, repo, id) + if err != nil { + return Milestone{}, err + } + deadline = current.Deadline + } + edit := gitea.EditMilestoneOption{Title: deref(opts.Title), Description: opts.Description, Deadline: deadline} + if opts.State != nil { + state := gitea.StateType(*opts.State) + edit.State = &state + } + milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) { + return c.Repositories.EditMilestone(ctx, owner, repo, id, edit) + }) + if err != nil { + return Milestone{}, err + } + return milestoneFrom(milestone), nil +} + +// Delete deletes one milestone. +func (s Milestones) Delete(ctx context.Context, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Repositories.DeleteMilestone(ctx, owner, repo, id) + }) +} + +// View retrieves one repository milestone. +func (s Milestones) View(ctx context.Context, id int64) (Milestone, error) { + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return Milestone{}, err + } + milestone, err := s.get(ctx, owner, repo, id) + if err != nil { + return Milestone{}, err + } + return milestoneFrom(milestone), nil +} + +func (s Milestones) get(ctx context.Context, owner, repo string, id int64) (*gitea.Milestone, error) { + milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) { + return c.Repositories.GetMilestone(ctx, owner, repo, id) + }) + return milestone, err +} + +// IssueEdit distinguishes omitted fields from explicit clears. +type IssueEdit struct { + Labels *[]int64 `json:"-"` + Ref *string `json:"ref,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + Milestone *int64 `json:"milestone,omitempty"` + Deadline *time.Time `json:"due_date,omitempty"` + RemoveDeadline *bool `json:"unset_due_date,omitempty"` + Changes *MetadataChanges `json:"-"` +} + +// MetadataChanges adds and removes labels and assignees relative to an issue's current state. +type MetadataChanges struct { + AddLabels, RemoveLabels []int64 + AddAssignees, RemoveAssignees []string +} + +// Edit updates only supplied issue fields. +func (s Issues) Edit(ctx context.Context, n int64, opts IssueEdit) (Issue, error) { + if opts.Changes != nil { + var err error + if opts, err = s.applyChanges(ctx, n, opts); err != nil { + return Issue{}, err + } + } + if opts.Assignees != nil { + names := mergeUnique(*opts.Assignees, nil, nil, strings.EqualFold) + opts.Assignees = &names + } + if opts.Labels != nil { + if err := s.replaceLabels(ctx, n, append([]int64{}, (*opts.Labels)...)); err != nil { + return Issue{}, err + } + opts.Labels = nil + var item Issue + var err error + if opts == (IssueEdit{}) { + item, err = s.View(ctx, n) + } else { + item, err = s.Edit(ctx, n, opts) + } + if err != nil { + return Issue{}, fmt.Errorf("labels were updated, but completing the issue edit failed: %w", err) + } + return item, nil + } + edit := gitea.EditIssueOption{ + Title: deref(opts.Title), Body: opts.Body, Ref: opts.Ref, Milestone: opts.Milestone, + Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline, + } + if opts.Assignees != nil { + edit.Assignees = *opts.Assignees + } + return s.edit(ctx, n, edit) +} + +func (s Issues) replaceLabels(ctx context.Context, n int64, ids []int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + _, resp, err := c.Issues.ReplaceIssueLabels(ctx, owner, repo, n, gitea.IssueLabelsOption{Labels: ids}) + return resp, err + }) +} + +// applyChanges replaces incremental changes with the full label and assignee sets Gitea +// expects, read from the issue's current state; Gitea cannot add or remove single assignees. +func (s Issues) applyChanges(ctx context.Context, n int64, opts IssueEdit) (IssueEdit, error) { + changes := *opts.Changes + opts.Changes = nil + current, err := s.View(ctx, n) + if err != nil { + return opts, err + } + if len(changes.AddLabels)+len(changes.RemoveLabels) > 0 { + ids := make([]int64, len(current.Labels)) + for i, label := range current.Labels { + ids[i] = label.ID + } + ids = mergeUnique(ids, changes.AddLabels, changes.RemoveLabels, func(a, b int64) bool { return a == b }) + opts.Labels = &ids + } + if len(changes.AddAssignees)+len(changes.RemoveAssignees) > 0 { + names := mergeUnique(current.Assignees, changes.AddAssignees, changes.RemoveAssignees, strings.EqualFold) + opts.Assignees = &names + } + return opts, nil +} + +// mergeUnique applies additions and removals to current values, preserving order. +func mergeUnique[T any](current, add, remove []T, equal func(T, T) bool) []T { + result := []T{} + for _, item := range slices.Concat(current, add) { + matches := func(other T) bool { return equal(item, other) } + if !slices.ContainsFunc(remove, matches) && !slices.ContainsFunc(result, matches) { + result = append(result, item) + } + } + return result +} + +// RemoveMilestone refuses to clear an issue belonging to another milestone. +func (s Issues) RemoveMilestone(ctx context.Context, n, id int64) (Issue, error) { + current, err := s.View(ctx, n) + if err != nil { + return Issue{}, err + } + if current.Milestone == nil || current.Milestone.ID != id { + return Issue{}, fmt.Errorf("issue is not assigned to milestone %d", id) + } + cleared := int64(0) + return s.Edit(ctx, n, IssueEdit{Milestone: &cleared}) +} diff --git a/internal/services/metadata_test.go b/internal/services/metadata_test.go new file mode 100644 index 00000000..64224552 --- /dev/null +++ b/internal/services/metadata_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOrganizationLabelArchivingUsesRawRequests(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, r.Method+" "+r.URL.Path+" "+string(body)) + _, _ = w.Write([]byte(`{"id":5,"name":"bug","color":"ee0701","is_archived":true}`)) + })) + service := Labels{Client: client, Organization: "company"} + name, color, archived, active := "bug", "ee0701", true, false + + label, err := service.Create(context.Background(), LabelOptions{Name: &name, Color: &color, Archived: &archived}) + require.NoError(t, err) + require.Equal(t, Label{ID: 5, Name: "bug", Color: "ee0701", Archived: true}, label) + _, err = service.Create(context.Background(), LabelOptions{Name: &name, Color: &color, Archived: &active}) + require.NoError(t, err) + _, err = service.Edit(context.Background(), 5, LabelOptions{Archived: &active}) + require.NoError(t, err) + _, err = service.Edit(context.Background(), 5, LabelOptions{Name: &name}) + require.NoError(t, err) + + require.Len(t, requests, 4) + assert.Equal(t, `POST /api/v1/orgs/company/labels {"name":"bug","color":"ee0701","is_archived":true}`, requests[0]) + assert.NotContains(t, requests[1], "is_archived") + assert.Equal(t, `PATCH /api/v1/orgs/company/labels/5 {"is_archived":false}`, requests[2]) + assert.NotContains(t, requests[3], "is_archived") + assert.Contains(t, requests[3], `"name":"bug"`) +} + +func TestRepositoryLabelEditSendsOnlySuppliedFields(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PATCH /api/v1/repos/alice/project/labels/5", r.Method+" "+r.URL.Path) + var body gitea.EditLabelOption + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + archived := true + assert.Equal(t, gitea.EditLabelOption{IsArchived: &archived}, body) + _, _ = w.Write([]byte(`{"id":5,"name":"bug","is_archived":true}`)) + })) + archived := true + label, err := (Labels{Client: client, Repository: "alice/project"}).Edit(context.Background(), 5, LabelOptions{Archived: &archived}) + require.NoError(t, err) + require.True(t, label.Archived) +} + +func TestMilestoneFromMapsServerFields(t *testing.T) { + deadline := "2028-01-15T23:59:59-08:00" + require.Equal(t, Milestone{ID: 1, Title: "v1", Description: "First", State: "closed", Deadline: &deadline, OpenIssues: 2, ClosedIssues: 3}, + milestoneFrom(decode[gitea.Milestone](t, `{"id":1,"title":"v1","description":"First","state":"closed","due_on":"2028-01-15T23:59:59-08:00","open_issues":2,"closed_issues":3}`))) +} + +func TestMilestoneRejectsInvalidDeadlineBeforeRequests(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + })) + service := Milestones{Client: client, Repository: "alice/project"} + invalid := "tomorrow" + _, err := service.Create(context.Background(), MilestoneOptions{Deadline: &invalid}) + require.ErrorContains(t, err, "invalid milestone deadline") + _, err = service.Edit(context.Background(), 1, MilestoneOptions{Deadline: &invalid}) + require.ErrorContains(t, err, "invalid milestone deadline") +} diff --git a/internal/services/milestone_deadline_test.go b/internal/services/milestone_deadline_test.go new file mode 100644 index 00000000..f89f4614 --- /dev/null +++ b/internal/services/milestone_deadline_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMilestoneEditPreservesDeadlineUnlessExplicitlyCleared(t *testing.T) { + reads := 0 + want := "2028-01-15T23:59:59-08:00" + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + reads++ + _, _ = w.Write([]byte(`{"id":9,"due_on":"2028-01-15T23:59:59-08:00"}`)) + return + } + options := requestJSON(t, r) + require.Equal(t, want, options["due_on"]) + _, _ = w.Write([]byte(`{"id":9}`)) + })) + service := Milestones{Client: client, Repository: "alice/project"} + state := "closed" + _, err := service.Edit(context.Background(), 9, MilestoneOptions{State: &state}) + require.NoError(t, err) + require.Equal(t, 1, reads) + want = "0001-01-01T00:00:00Z" + _, err = service.Edit(context.Background(), 9, MilestoneOptions{Deadline: &want}) + require.NoError(t, err) + require.Equal(t, 1, reads) +} diff --git a/internal/services/milestone_selection.go b/internal/services/milestone_selection.go new file mode 100644 index 00000000..f091ed2a --- /dev/null +++ b/internal/services/milestone_selection.go @@ -0,0 +1,35 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "strings" +) + +// ResolveName selects an exact title across open and closed milestones. +func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error) { + if strings.TrimSpace(name) == "" { + return 0, fmt.Errorf("milestone name cannot be empty") + } + items, err := listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) }) + if err != nil { + return 0, err + } + var id int64 + for _, item := range items { + if item.Title != name { + continue + } + if id != 0 { + return 0, fmt.Errorf("milestone name %q is ambiguous; select its numeric ID", name) + } + id = item.ID + } + if id == 0 { + return 0, fmt.Errorf("milestone %q does not exist; use tea milestone list --state all", name) + } + return id, nil +} diff --git a/internal/services/milestone_selection_test.go b/internal/services/milestone_selection_test.go new file mode 100644 index 00000000..f85a5b58 --- /dev/null +++ b/internal/services/milestone_selection_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMilestoneNamesIncludeClosedAndRejectAmbiguity(t *testing.T) { + duplicate := false + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "all", r.URL.Query().Get("state")) + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`[{"id":1,"title":"Other"}]`)) + case "2": + _, _ = w.Write([]byte(`[{"id":2,"title":"Release","state":"closed"}]`)) + case "3": + if duplicate { + _, _ = w.Write([]byte(`[{"id":3,"title":"Release"}]`)) + return + } + fallthrough + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + service := Milestones{Client: client, Repository: "alice/project"} + id, err := service.ResolveName(context.Background(), "Release") + require.NoError(t, err) + require.EqualValues(t, 2, id) + _, err = service.ResolveName(context.Background(), "missing") + require.ErrorContains(t, err, "does not exist") + duplicate = true + _, err = service.ResolveName(context.Background(), "Release") + require.ErrorContains(t, err, "ambiguous") +} diff --git a/internal/services/notifications.go b/internal/services/notifications.go new file mode 100644 index 00000000..140cc66b --- /dev/null +++ b/internal/services/notifications.go @@ -0,0 +1,176 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Notification is the stable representation of an account notification thread. +type Notification struct { + ID int64 `json:"id"` + Repository string `json:"repository"` + Title string `json:"title"` + URL string `json:"url"` + Type string `json:"type"` + State string `json:"state"` + Unread bool `json:"unread"` + Pinned bool `json:"pinned"` + UpdatedAt string `json:"updated_at"` +} + +func notificationFrom(thread *gitea.NotificationThread) Notification { + result := Notification{ID: thread.ID, Unread: thread.Unread, Pinned: thread.Pinned, UpdatedAt: timestamp(thread.UpdatedAt)} + if thread.Repository != nil { + result.Repository = thread.Repository.FullName + } + if thread.Subject != nil { + result.Title = thread.Subject.Title + result.URL = thread.Subject.HTMLURL + result.Type = string(thread.Subject.Type) + result.State = string(thread.Subject.State) + } + return result +} + +func notificationStatuses(statuses []string) []gitea.NotificationStatus { + result := make([]gitea.NotificationStatus, 0, len(statuses)) + for _, status := range statuses { + result = append(result, gitea.NotificationStatus(status)) + } + return result +} + +// NotificationOptions supplies pagination and server-supported filters. +type NotificationOptions struct { + Limit, Page int + Statuses, Types []string + Since, Before *time.Time +} + +func (o NotificationOptions) sdk() gitea.ListNotificationOptions { + result := gitea.ListNotificationOptions{ListOptions: listOptions(o.Limit, o.Page), Status: notificationStatuses(o.Statuses)} + for _, kind := range o.Types { + result.SubjectTypes = append(result.SubjectTypes, gitea.NotificationSubjectType(kind)) + } + if o.Since != nil { + result.Since = *o.Since + } + if o.Before != nil { + result.Before = *o.Before + } + return result +} + +// Notifications manages account threads, optionally scoped to one repository. +type Notifications struct { + Client *transport.Client + Repository string +} + +// List returns a non-nil page of notifications. +func (s Notifications) List(ctx context.Context, opts NotificationOptions) ([]Notification, error) { + threads, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) { + if s.Repository == "" { + return c.Notifications.List(ctx, opts.sdk()) + } + owner, name, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Notifications.ListByRepo(ctx, owner, name, opts.sdk()) + }) + if err != nil { + return nil, err + } + return convertAll(threads, notificationFrom), nil +} + +// View retrieves an account-owned notification thread. +func (s Notifications) View(ctx context.Context, id int64) (Notification, error) { + thread, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.NotificationThread, *gitea.Response, error) { + return c.Notifications.GetByID(ctx, id) + }) + if err != nil { + return Notification{}, err + } + item := notificationFrom(thread) + if s.Repository != "" && !strings.EqualFold(item.Repository, s.Repository) { + return Notification{}, fmt.Errorf("notification %d belongs to %s, not selected repository %s", id, item.Repository, s.Repository) + } + return item, nil +} + +// Mark changes the selected notification status. +func (s Notifications) Mark(ctx context.Context, id int64, status string) (Notification, error) { + if s.Repository != "" { + if _, err := s.View(ctx, id); err != nil { + return Notification{}, err + } + } + thread, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.NotificationThread, *gitea.Response, error) { + return c.Notifications.MarkReadByID(ctx, id, gitea.NotificationStatus(status)) + }) + if err != nil { + return Notification{}, err + } + return notificationFrom(thread), nil +} + +// MarkAll applies a status to all matching notifications within the selected scope. +func (s Notifications) MarkAll(ctx context.Context, status string, filters []string, before time.Time) ([]Notification, error) { + opts := gitea.MarkNotificationOptions{LastReadAt: before, Status: notificationStatuses(filters), ToStatus: gitea.NotificationStatus(status)} + threads, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) { + if s.Repository == "" { + return c.Notifications.MarkRead(ctx, opts) + } + owner, name, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Notifications.MarkReadByRepo(ctx, owner, name, opts) + }) + if err != nil { + return nil, err + } + return convertAll(threads, notificationFrom), nil +} + +// Watch reports new or changed threads and propagates cancellation and callback errors. +func (s Notifications) Watch(ctx context.Context, opts NotificationOptions, interval time.Duration, onChange func([]Notification) error) error { + if interval <= 0 { + return errors.New("notification polling interval must be positive") + } + seen := map[int64]Notification{} + for { + items, err := s.List(ctx, opts) + if err != nil { + return err + } + changes := []Notification{} + next := map[int64]Notification{} + for _, item := range items { + next[item.ID] = item + if previous, exists := seen[item.ID]; !exists || previous != item { + changes = append(changes, item) + } + } + seen = next + if len(changes) > 0 { + if err := onChange(changes); err != nil { + return err + } + } + if err := sleepCtx(ctx, interval); err != nil { + return err + } + } +} diff --git a/internal/services/notifications_test.go b/internal/services/notifications_test.go new file mode 100644 index 00000000..09ff92e6 --- /dev/null +++ b/internal/services/notifications_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + "time" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestNotificationWatchOnlyEmitsChanges(t *testing.T) { + calls := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + require.Equal(t, "/api/v1/repos/alice/project/notifications", r.URL.Path) + require.Equal(t, []string{"unread", "pinned"}, r.URL.Query()["status-types"]) + if calls < 3 { + _, _ = w.Write([]byte(`[{"id":1,"unread":true,"subject":{"title":"First"}}]`)) + } else { + _, _ = w.Write([]byte(`[{"id":1,"unread":true,"subject":{"title":"Changed"}}]`)) + } + })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + service := Notifications{Client: client, Repository: "alice/project"} + batches := [][]Notification{} + err := service.Watch(ctx, NotificationOptions{Limit: 30, Page: 1, Statuses: []string{"unread", "pinned"}}, time.Millisecond, func(items []Notification) error { + batches = append(batches, items) + if len(batches) == 2 { + cancel() + } + return nil + }) + require.ErrorIs(t, err, context.Canceled) + require.Len(t, batches, 2) + require.Equal(t, "Changed", batches[1][0].Title) +} + +func TestNotificationRepositoryMismatchPreventsMutation(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + mutations++ + } + _, _ = w.Write([]byte(`{"id":1,"repository":{"full_name":"bob/other"}}`)) + })) + service := Notifications{Client: client, Repository: "alice/project"} + _, err := service.Mark(context.Background(), 1, "read") + require.ErrorContains(t, err, "not selected repository") + require.Zero(t, mutations) +} + +func TestNotificationRepositoryMatchIgnoresCase(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + mutations++ + } + _, _ = w.Write([]byte(`{"id":1,"repository":{"full_name":"Alice/Project"}}`)) + })) + _, err := (Notifications{Client: client, Repository: "alice/project"}).Mark(context.Background(), 1, "read") + require.NoError(t, err) + require.Equal(t, 1, mutations) +} + +func TestNotificationWatchRejectsNonPositiveInterval(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + err := (Notifications{Client: client}).Watch(context.Background(), NotificationOptions{Limit: 30, Page: 1}, 0, func([]Notification) error { return nil }) + require.ErrorContains(t, err, "interval must be positive") +} + +func TestNotificationMarkAllScopesRepositoryAndFilters(t *testing.T) { + before := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + require.Equal(t, "/api/v1/repos/alice/project/notifications", r.URL.Path) + require.Equal(t, "read", r.URL.Query().Get("to-status")) + require.Equal(t, []string{"unread", "pinned"}, r.URL.Query()["status-types"]) + require.Equal(t, "2026-10-01T12:00:00Z", r.URL.Query().Get("last_read_at")) + w.WriteHeader(http.StatusResetContent) + _, _ = w.Write([]byte(`[{"id":2,"repository":{"full_name":"alice/project"},"subject":{"title":"Bug","type":"Issue","state":"open"}}]`)) + })) + items, err := (Notifications{Client: client, Repository: "alice/project"}).MarkAll(context.Background(), "read", []string{"unread", "pinned"}, before) + require.NoError(t, err) + require.Equal(t, []Notification{{ID: 2, Repository: "alice/project", Title: "Bug", Type: "Issue", State: "open"}}, items) +} + +func TestNotificationListSendsFilters(t *testing.T) { + since := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/notifications", r.URL.Path) + query := r.URL.Query() + require.Equal(t, "20", query.Get("limit")) + require.Equal(t, "2", query.Get("page")) + require.Equal(t, []string{"Issue", "Pull"}, query["subject-type"]) + require.Equal(t, "2026-10-01T00:00:00Z", query.Get("since")) + require.Empty(t, query.Get("before")) + _, _ = w.Write([]byte(`[]`)) + })) + items, err := (Notifications{Client: client}).List(context.Background(), NotificationOptions{Limit: 20, Page: 2, Types: []string{"Issue", "Pull"}, Since: &since}) + require.NoError(t, err) + require.Equal(t, []Notification{}, items) +} + +func TestNotificationFromMapsServerFields(t *testing.T) { + thread := decode[gitea.NotificationThread](t, `{"id":5,"repository":{"full_name":"alice/project"},"unread":true,"pinned":true, + "updated_at":"2026-10-02T12:00:00+02:00","subject":{"title":"Fix","html_url":"https://gitea.test/alice/project/pulls/1","type":"Pull","state":"merged"}}`) + require.Equal(t, Notification{ + ID: 5, Repository: "alice/project", Title: "Fix", URL: "https://gitea.test/alice/project/pulls/1", Type: "Pull", State: "merged", + Unread: true, Pinned: true, UpdatedAt: "2026-10-02T12:00:00+02:00", + }, notificationFrom(thread)) + require.Equal(t, Notification{ID: 6}, notificationFrom(decode[gitea.NotificationThread](t, `{"id":6,"repository":null,"subject":null}`))) +} diff --git a/internal/services/organizations.go b/internal/services/organizations.go new file mode 100644 index 00000000..53d6d0c3 --- /dev/null +++ b/internal/services/organizations.go @@ -0,0 +1,160 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Organization is the public command representation of a Gitea organization. +type Organization struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Website string `json:"website"` + Location string `json:"location"` + Visibility string `json:"visibility"` + RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"` +} + +// organization returns the ID of owner when it is an organization; a missing organization +// means a user owns the namespace. +func organization(ctx context.Context, client *transport.Client, owner string) (id int64, isOrg bool, err error) { + org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { + return c.Organizations.GetOrg(ctx, owner) + }) + if failure, ok := errors.AsType[*transport.APIError](err); ok && failure.Status == http.StatusNotFound { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + if org.ID < 1 { + return 0, false, fmt.Errorf("cannot verify the identity of organization %s", owner) + } + return org.ID, true, nil +} + +// OrganizationOptions permits explicit empty and false preference updates. +type OrganizationOptions struct { + Name *string `json:"username,omitempty"` + FullName *string `json:"full_name,omitempty"` + Description *string `json:"description,omitempty"` + Website *string `json:"website,omitempty"` + Location *string `json:"location,omitempty"` + Visibility *string `json:"visibility,omitempty"` + RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access,omitempty"` +} + +// Organizations implements instance-scoped organization workflows. +type Organizations struct{ Client *transport.Client } + +// List selects public organizations, your organizations, or a named user's organizations. +// It reports whether more organizations follow the selected page. +func (s Organizations) List(ctx context.Context, mine bool, username string, limit, page int) ([]Organization, bool, error) { + orgs, more, err := listWindow(limit, page, func(limit, page int) ([]*gitea.Organization, http.Header, error) { + orgs, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Organization, *gitea.Response, error) { + opts := gitea.ListOrgsOptions{ListOptions: listOptions(limit, page)} + switch { + case mine: + return c.Organizations.ListMyOrgs(ctx, opts) + case username != "": + return c.Organizations.ListUserOrgs(ctx, username, opts) + } + return c.Organizations.ListOrgs(ctx, opts) + }) + return orgs, headerOf(resp), err + }) + if err != nil { + return nil, false, err + } + return convertAll(orgs, organizationFrom), more, nil +} + +// View retrieves a named organization. +func (s Organizations) View(ctx context.Context, name string) (Organization, error) { + org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { + return c.Organizations.GetOrg(ctx, name) + }) + if err != nil { + return Organization{}, err + } + return organizationFrom(org), nil +} + +// Create creates an organization owned by the selected account. +func (s Organizations) Create(ctx context.Context, opts OrganizationOptions) (Organization, error) { + org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { + return c.Organizations.CreateOrg(ctx, gitea.CreateOrgOption{ + Name: deref(opts.Name), FullName: deref(opts.FullName), Description: deref(opts.Description), + Website: deref(opts.Website), Location: deref(opts.Location), Visibility: gitea.VisibleType(deref(opts.Visibility)), + RepoAdminChangeTeamAccess: deref(opts.RepoAdminChangeTeamAccess), + }) + }) + if err != nil { + return Organization{}, err + } + return organizationFrom(org), nil +} + +// Edit changes only supplied organization preferences. +func (s Organizations) Edit(ctx context.Context, name string, opts OrganizationOptions) (Organization, error) { + var org gitea.Organization + // TODO(sdk): EditOrgOption needs pointer fields so omitted preferences stay unchanged, and EditOrg should return the organization. + if err := sendJSON(ctx, s.Client, http.MethodPatch, "orgs/"+url.PathEscape(name), opts, &org); err != nil { + return Organization{}, err + } + return organizationFrom(&org), nil +} + +// Delete deletes the named organization. +func (s Organizations) Delete(ctx context.Context, name string) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Organizations.DeleteOrg(ctx, name) + }) +} + +// OrganizationMember identifies a member without exposing private profile fields. +type OrganizationMember struct { + ID int64 `json:"id"` + Username string `json:"username"` + FullName string `json:"full_name"` +} + +// Members lists visible members in an organization. +func (s Organizations) Members(ctx context.Context, name string, public bool, limit, page int) ([]OrganizationMember, error) { + users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { + opts := gitea.ListOrgMembershipOption{ListOptions: listOptions(limit, page)} + if public { + return c.Organizations.ListPublicOrgMembership(ctx, name, opts) + } + return c.Organizations.ListOrgMembership(ctx, name, opts) + }) + if err != nil { + return nil, err + } + return convertAll(users, memberFrom), nil +} + +// RemoveMember removes an organization member and their organization access. +func (s Organizations) RemoveMember(ctx context.Context, name, username string) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Organizations.DeleteOrgMembership(ctx, name, username) + }) +} + +// SetMemberPublic changes public visibility of an existing membership. +func (s Organizations) SetMemberPublic(ctx context.Context, name, username string, public bool) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Organizations.SetPublicOrgMembership(ctx, name, username, public) + }) +} diff --git a/internal/services/organizations_test.go b/internal/services/organizations_test.go new file mode 100644 index 00000000..273bb279 --- /dev/null +++ b/internal/services/organizations_test.go @@ -0,0 +1,130 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "net/http" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestOrganizationEditsPreserveOmittedFields(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + require.Equal(t, "/api/v1/orgs/team", r.URL.Path) + body := requestJSON(t, r) + require.Len(t, body, 2) + require.Equal(t, "", body["description"]) + require.Equal(t, false, body["repo_admin_change_team_access"]) + _, _ = w.Write([]byte(`{"id":1,"username":"team","visibility":"private"}`)) + })) + description := "" + access := false + service := Organizations{Client: client} + item, err := service.Edit(context.Background(), "team", OrganizationOptions{Description: &description, RepoAdminChangeTeamAccess: &access}) + require.NoError(t, err) + require.Equal(t, "team", item.Name) + require.Equal(t, "private", item.Visibility) +} + +func TestOrganizationLookupTreatsMissingOrganizationAsUser(t *testing.T) { + for name, tc := range map[string]struct { + status int + body string + wantID int64 + wantOrg bool + wantStatusErr int + }{ + "organization": {status: http.StatusOK, body: `{"id":9,"name":"acme"}`, wantID: 9, wantOrg: true}, + "user namespace": {status: http.StatusNotFound, body: `{"message":"not found"}`}, + "server failure": {status: http.StatusInternalServerError, body: `{}`, wantStatusErr: http.StatusInternalServerError}, + } { + t.Run(name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/orgs/acme", r.URL.Path) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + id, isOrg, err := organization(context.Background(), client, "acme") + if tc.wantStatusErr != 0 { + var failure *transport.APIError + require.True(t, errors.As(err, &failure)) + require.Equal(t, tc.wantStatusErr, failure.Status) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantID, id) + require.Equal(t, tc.wantOrg, isOrg) + }) + } +} + +func TestOrganizationListSelectsScopeAndPage(t *testing.T) { + for name, tc := range map[string]struct { + mine bool + username string + path string + }{ + "public": {path: "/api/v1/orgs"}, + "mine": {mine: true, path: "/api/v1/user/orgs"}, + "user": {username: "alice", path: "/api/v1/users/alice/orgs"}, + } { + t.Run(name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, tc.path, r.URL.Path) + require.Equal(t, "2", r.URL.Query().Get("limit")) + require.Equal(t, "3", r.URL.Query().Get("page")) + w.Header().Set("X-Total-Count", "7") + _, _ = w.Write([]byte(`[{"id":1,"username":"legacy"},{"id":2,"name":"acme"}]`)) + })) + items, more, err := (Organizations{Client: client}).List(context.Background(), tc.mine, tc.username, 2, 3) + require.NoError(t, err) + require.True(t, more) + require.Equal(t, []string{"legacy", "acme"}, []string{items[0].Name, items[1].Name}) + }) + } +} + +func TestOrganizationCreateSendsSuppliedPreferences(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/orgs", r.URL.Path) + body := requestJSON(t, r) + require.Equal(t, "acme", body["username"]) + require.Equal(t, "limited", body["visibility"]) + require.Equal(t, true, body["repo_admin_change_team_access"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":5,"name":"acme","visibility":"limited","repo_admin_change_team_access":true}`)) + })) + name, visibility, access := "acme", "limited", true + item, err := (Organizations{Client: client}).Create(context.Background(), OrganizationOptions{Name: &name, Visibility: &visibility, RepoAdminChangeTeamAccess: &access}) + require.NoError(t, err) + require.Equal(t, Organization{ID: 5, Name: "acme", Visibility: "limited", RepoAdminChangeTeamAccess: true}, item) +} + +func TestOrganizationMembershipRequests(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"id":2,"login":"bob","full_name":"Bob"}]`)) + return + } + w.WriteHeader(http.StatusNoContent) + })) + service := Organizations{Client: client} + members, err := service.Members(context.Background(), "acme", true, 10, 1) + require.NoError(t, err) + require.Equal(t, []OrganizationMember{{ID: 2, Username: "bob", FullName: "Bob"}}, members) + require.NoError(t, service.SetMemberPublic(context.Background(), "acme", "bob", true)) + require.NoError(t, service.RemoveMember(context.Background(), "acme", "bob")) + require.Equal(t, []string{ + "GET /api/v1/orgs/acme/public_members", "PUT /api/v1/orgs/acme/public_members/bob", + "DELETE /api/v1/orgs/acme/members/bob", + }, requests) +} diff --git a/internal/services/pagination_test.go b/internal/services/pagination_test.go new file mode 100644 index 00000000..1e9ddbb1 --- /dev/null +++ b/internal/services/pagination_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +// cappedServer serves total items in pages of at most maxItems, like Gitea's MAX_RESPONSE_ITEMS. +func cappedServer(t *testing.T, total, maxItems int, link, count bool) (*httptest.Server, *int) { + t.Helper() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + require.NoError(t, err) + page, err := strconv.Atoi(r.URL.Query().Get("page")) + require.NoError(t, err) + size := min(limit, maxItems) + items := []string{} + for id := (page-1)*size + 1; id <= min(page*size, total); id++ { + items = append(items, fmt.Sprintf(`{"id":%d,"name":"n%d","full_name":"o/n%d"}`, id, id, id)) + } + if count { + w.Header().Set("X-Total-Count", strconv.Itoa(total)) + } + if link && page*size < total { + w.Header().Set("Link", fmt.Sprintf(`<%s%s?limit=%d&page=%d>; rel="next"`, "http://"+r.Host, r.URL.Path, limit, page+1)) + } + _, _ = w.Write([]byte("[" + strings.Join(items, ",") + "]")) + })) + t.Cleanup(server.Close) + return server, &requests +} + +func TestListsCollectPagesBeyondServerPageSize(t *testing.T) { + for _, tc := range []struct { + total, maxItems, limit, page int + link, count bool + }{ + {120, 50, 100, 1, true, true}, + {120, 50, 100, 2, true, true}, + {120, 50, 60, 2, true, true}, + {120, 50, 30, 3, true, true}, + {120, 50, 100, 3, true, true}, + {100, 50, 100, 2, true, true}, + {110, 60, 100, 2, true, true}, + {120, 50, 100, 1, true, false}, + {120, 50, 100, 2, true, false}, + {120, 50, 100, 1, false, true}, + {120, 50, 100, 2, false, true}, + {110, 60, 100, 2, false, true}, + {40, 50, 100, 1, false, false}, + {120, 100, 100, 2, true, true}, + } { + t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) { + server, requests := cappedServer(t, tc.total, tc.maxItems, tc.link, tc.count) + want := []int64{} + for id := (tc.page-1)*tc.limit + 1; id <= min(tc.page*tc.limit, tc.total); id++ { + want = append(want, int64(id)) + } + client := transport.New(server.URL, "", false) + orgs, more, err := (Organizations{Client: client}).List(context.Background(), true, "", tc.limit, tc.page) + require.NoError(t, err) + got := []int64{} + for _, item := range orgs { + got = append(got, item.ID) + } + require.Equal(t, want, got) + require.Equal(t, tc.total > tc.page*tc.limit, more) + require.LessOrEqual(t, *requests, tc.limit/min(tc.limit, tc.maxItems)+2) + repos, more, err := (Repositories{Client: client}).List(context.Background(), RepositoryListOptions{Limit: tc.limit, Page: tc.page}) + require.NoError(t, err) + got = []int64{} + for _, item := range repos { + got = append(got, item.ID) + } + require.Equal(t, want, got) + require.Equal(t, tc.total > tc.page*tc.limit, more) + }) + } +} diff --git a/internal/services/pull_create_selectables.go b/internal/services/pull_create_selectables.go new file mode 100644 index 00000000..2591177c --- /dev/null +++ b/internal/services/pull_create_selectables.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + + "gitea.dev/tea/internal/transport" +) + +// CreateSelectables are the metadata choices offered by create forms; the lists stay empty unless CanPush. +type CreateSelectables struct { + CanPush bool + Assignees []string + Milestones []Milestone + Labels []Label +} + +// CreateSelectables loads assignees/milestones/labels when the user can push. +func (s Pulls) CreateSelectables(ctx context.Context) (CreateSelectables, error) { + return loadCreateSelectables(ctx, s.Client, s.Repository) +} + +func loadCreateSelectables(ctx context.Context, client *transport.Client, repository string) (CreateSelectables, error) { + result := CreateSelectables{Assignees: []string{}, Milestones: []Milestone{}, Labels: []Label{}} + permissions, err := (Repositories{Client: client}).viewPermissions(ctx, repository) + if err != nil || !permissions.Push { + return result, err + } + result.CanPush = true + if result.Assignees, err = (Repositories{Client: client}).assignees(ctx, repository); err != nil { + return result, err + } + if result.Milestones, err = listAll(func(page int) ([]Milestone, error) { + return (Milestones{Client: client, Repository: repository}).List(ctx, "open", 100, page) + }); err != nil { + return result, err + } + result.Labels, err = (Labels{Client: client, Repository: repository}).All(ctx) + return result, err +} diff --git a/internal/services/pulls.go b/internal/services/pulls.go new file mode 100644 index 00000000..86a0f174 --- /dev/null +++ b/internal/services/pulls.go @@ -0,0 +1,378 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Pull is the stable command representation of a pull request. +type Pull struct { + Author string `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Assignees []string `json:"assignees"` + Milestone *IssueMilestone `json:"milestone"` + Labels []Label `json:"labels"` + Deadline *string `json:"due_date"` + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + Merged bool `json:"merged"` + Mergeable bool `json:"mergeable"` + Draft bool `json:"-"` + Base string `json:"base"` + Head string `json:"head"` + HeadSHA string `json:"head_sha"` + HeadRepo string `json:"head_repo,omitempty"` + HeadRemoteDeleted bool `json:"head_remote_deleted"` + HeadCanPush bool `json:"-"` + HeadCloneURL string `json:"-"` + HeadSSHURL string `json:"-"` + Reviewers []string `json:"reviewers"` + ReviewerTeams []string `json:"reviewer_teams"` + Comments int64 `json:"comments"` +} + +func pullHeadBranch(number int64, ref, label string) (branch string, deleted bool) { + if ref == fmt.Sprintf("refs/pull/%d/head", number) { + branch = label + if _, name, ok := strings.Cut(label, ":"); ok && name != "" { + branch = name + } + return branch, true + } + return ref, false +} + +func pullFrom(pull *gitea.PullRequest) Pull { + var head, base gitea.PRBranchInfo + if pull.Head != nil { + head = *pull.Head + } + if pull.Base != nil { + base = *pull.Base + } + headBranch, remoteDeleted := pullHeadBranch(pull.Index, head.Ref, head.Name) + result := Pull{ + Author: login(pull.Poster), CreatedAt: timestamp(deref(pull.Created)), UpdatedAt: timestamp(deref(pull.Updated)), + Assignees: convertAll(pull.Assignees, login), Milestone: issueMilestoneFrom(pull.Milestone), Labels: convertAll(pull.Labels, labelFrom), + Deadline: optionalTimestamp(pull.Deadline), ID: pull.ID, Number: pull.Index, Title: pull.Title, Body: pull.Body, + URL: pull.HTMLURL, State: string(pull.State), Merged: pull.HasMerged, Mergeable: pull.Mergeable, Draft: pull.Draft, + Base: base.Ref, Head: headBranch, HeadSHA: head.Sha, HeadRemoteDeleted: remoteDeleted, + Reviewers: convertAll(pull.RequestedReviewers, login), Comments: int64(pull.Comments), + ReviewerTeams: convertAll(pull.RequestedReviewersTeams, func(team *gitea.Team) string { return team.Name }), + } + repo := head.Repository + if remoteDeleted || repo == nil { + repo = base.Repository + } + if repo != nil { + result.HeadRepo, result.HeadCloneURL, result.HeadSSHURL = repo.FullName, repo.CloneURL, repo.SSHURL + if repo.Permissions != nil { + result.HeadCanPush = repo.Permissions.Push + } + } + return result +} + +// PullOptions supplies validated branch and content input for creation. +type PullOptions struct { + Assignees []string `json:"assignees,omitempty"` + Labels []int64 `json:"labels,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Deadline *time.Time `json:"due_date,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + Base string `json:"base"` + Head string `json:"head"` + AllowMaintainerEdit *bool `json:"allow_maintainer_edit,omitempty"` +} + +// PullEdit distinguishes omitted fields from explicit empty values. +type PullEdit struct { + Labels *[]int64 `json:"-"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` + Base *string `json:"base,omitempty"` + AllowMaintainerEdit *bool `json:"allow_maintainer_edit,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + Milestone *int64 `json:"-"` + ReviewerChanges *ReviewRequestChanges `json:"-"` + Deadline *time.Time `json:"due_date,omitempty"` + RemoveDeadline *bool `json:"unset_due_date,omitempty"` + Changes *MetadataChanges `json:"-"` +} + +// Pulls implements repository pull request operations. +type Pulls struct { + Client *transport.Client + Repository string +} + +// PullListOptions supplies server-supported pull request filters. +type PullListOptions struct { + State, Sort, Author, Base string + Limit, Page int + Milestone int64 + Labels []int64 +} + +// List returns a page of pull requests, even when the server caps its page size below the +// limit, and reports whether more pull requests follow. +func (s Pulls) List(ctx context.Context, options PullListOptions) ([]Pull, bool, error) { + query := url.Values{"state": {options.State}} + if options.Sort != "" { + query.Set("sort", options.Sort) + } + if options.Author != "" { + query.Set("poster", options.Author) + } + if options.Base != "" { + query.Set("base_branch", options.Base) + } + if options.Milestone > 0 { + query.Set("milestone", strconv.FormatInt(options.Milestone, 10)) + } + for _, label := range options.Labels { + query.Add("labels", strconv.FormatInt(label, 10)) + } + + // TODO(sdk): ListPullRequestsOptions lacks the poster, base_branch, and labels filters. + pulls, more, err := rawWindow[*gitea.PullRequest](ctx, s.Client, "repos/"+s.Repository+"/pulls", query, options.Limit, options.Page) + if err != nil { + return nil, false, err + } + return convertAll(pulls, pullFrom), more, nil +} + +// View retrieves one pull request. +func (s Pulls) View(ctx context.Context, n int64) (Pull, error) { + pull, err := s.get(ctx, n) + if err != nil { + return Pull{}, err + } + return pullFrom(pull), nil +} + +func (s Pulls) get(ctx context.Context, n int64) (*gitea.PullRequest, error) { + return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) { + return c.PullRequests.GetPullRequest(ctx, owner, name, n) + }) +} + +// ListOpen returns every open pull request in the repository. +func (s Pulls) ListOpen(ctx context.Context) ([]Pull, error) { + return listAll(func(page int) ([]Pull, error) { + items, _, err := s.List(ctx, PullListOptions{State: "open", Limit: 50, Page: page}) + return items, err + }) +} + +// FindOpenByHead returns the unique open pull whose head branch matches name. +func (s Pulls) FindOpenByHead(ctx context.Context, head string) (Pull, error) { + head = strings.TrimSpace(head) + if head == "" { + return Pull{}, fmt.Errorf("head branch cannot be empty") + } + items, err := s.ListOpen(ctx) + if err != nil { + return Pull{}, err + } + matches := []Pull{} + for _, item := range items { + if item.Head == head { + matches = append(matches, item) + } + } + switch len(matches) { + case 0: + return Pull{}, fmt.Errorf("no open pull request uses head branch %q", head) + case 1: + return matches[0], nil + default: + return Pull{}, fmt.Errorf("multiple open pull requests use head branch %q; supply an explicit number", head) + } +} + +// Create opens a pull request. +func (s Pulls) Create(ctx context.Context, opts PullOptions) (Pull, error) { + // TODO(sdk): CreatePullRequestOption lacks allow_maintainer_edit. + var pull gitea.PullRequest + if err := sendJSON(ctx, s.Client, http.MethodPost, "repos/"+s.Repository+"/pulls", opts, &pull); err != nil { + return Pull{}, err + } + return pullFrom(&pull), nil +} + +// Edit updates only explicitly supplied resource fields. +func (s Pulls) Edit(ctx context.Context, n int64, opts PullEdit) (Pull, error) { + if opts.ReviewerChanges != nil { + item, err := (Reviews{Client: s.Client, Repository: s.Repository}).ChangeRequests(ctx, n, *opts.ReviewerChanges) + if err != nil { + return Pull{}, err + } + opts.ReviewerChanges = nil + if opts == (PullEdit{}) { + return item, nil + } + item, err = s.Edit(ctx, n, opts) + if err != nil { + return Pull{}, fmt.Errorf("review requests were updated, but editing other pull request fields failed: %w", err) + } + return item, nil + } + issues := Issues{Client: s.Client, Repository: s.Repository} + if opts.Changes != nil { + resolved, err := issues.applyChanges(ctx, n, IssueEdit{Labels: opts.Labels, Assignees: opts.Assignees, Changes: opts.Changes}) + if err != nil { + return Pull{}, err + } + opts.Labels, opts.Assignees, opts.Changes = resolved.Labels, resolved.Assignees, nil + } + if opts.Milestone != nil || opts.Labels != nil { + // The PR endpoint ignores zero milestones. Its issue endpoint supports + // clearing and lets common fields share the same mutation. + _, err := issues.Edit(ctx, n, IssueEdit{ + Title: opts.Title, Body: opts.Body, Assignees: opts.Assignees, Milestone: opts.Milestone, Labels: opts.Labels, + Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline, + }) + if err != nil { + return Pull{}, err + } + opts.Title, opts.Body, opts.Assignees, opts.Milestone = nil, nil, nil, nil + opts.Deadline, opts.RemoveDeadline, opts.Labels = nil, nil, nil + var result Pull + if opts == (PullEdit{}) { + result, err = s.View(ctx, n) + } else { + result, err = s.Edit(ctx, n, opts) + } + if err != nil { + return Pull{}, fmt.Errorf("issue fields were updated, but completing the pull request edit failed: %w", err) + } + return result, nil + } + return repoConvert(ctx, s.Client, s.Repository, pullFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) { + return c.PullRequests.EditPullRequest(ctx, owner, name, n, editPullOption(opts)) + }) +} + +func editPullOption(opts PullEdit) gitea.EditPullRequestOption { + option := gitea.EditPullRequestOption{ + Body: opts.Body, Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline, AllowMaintainerEdit: opts.AllowMaintainerEdit, + } + if opts.Title != nil { + option.Title = *opts.Title + } + if opts.Base != nil { + option.Base = *opts.Base + } + if opts.Assignees != nil { + option.Assignees = *opts.Assignees + } + if opts.State != nil { + state := gitea.StateType(*opts.State) + option.State = &state + } + return option +} + +// Diff returns the pull request's patch, which the caller must close. +func (s Pulls) Diff(ctx context.Context, n int64) (io.ReadCloser, error) { + diff, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]byte, *gitea.Response, error) { + return c.PullRequests.GetPullRequestDiff(ctx, owner, name, n, gitea.PullRequestDiffOptions{}) + }) + if err != nil { + return nil, err + } + return io.NopCloser(bytes.NewReader(diff)), nil +} + +// MergeOptions supplies merge strategy and optional commit text. +type MergeOptions struct { + Method, Title, Message string + DeleteBranch bool +} + +// Merge merges the pull request's current head with the selected strategy; Gitea refuses the +// merge if the head moves meanwhile. When Gitea refuses the merge, the error names the reason +// derived from the pull request, since Gitea reports conflicts only as "try again later". +func (s Pulls) Merge(ctx context.Context, n int64, opts MergeOptions) error { + pull, err := s.get(ctx, n) + if err != nil { + return err + } + option := gitea.MergePullRequestOption{ + Style: gitea.MergeStyle(opts.Method), Title: opts.Title, Message: opts.Message, DeleteBranchAfterMerge: &opts.DeleteBranch, + } + if pull.Head != nil { + option.HeadCommitId = pull.Head.Sha + } + err = repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + _, resp, err := c.PullRequests.MergePullRequest(ctx, owner, name, n, option) + return resp, err + }) + if refused, ok := errors.AsType[*transport.APIError](err); !ok || refused.Status != http.StatusMethodNotAllowed { + return err + } + var reason string + switch { + case pull.HasMerged: + reason = "has already been merged" + case pull.State == gitea.StateClosed: + reason = "is closed" + case pull.Draft: + reason = "is a draft; mark it ready for review first" + case !pull.Mergeable: + reason = "has conflicting files or is otherwise not mergeable" + default: + return err + } + return fmt.Errorf("pull request #%d %s: %w", n, reason, err) +} + +// pullTemplatePaths lists the Markdown pull request templates Gitea reads, in its priority order. +var pullTemplatePaths = []string{ + "PULL_REQUEST_TEMPLATE.md", "pull_request_template.md", + ".gitea/PULL_REQUEST_TEMPLATE.md", ".gitea/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", ".github/pull_request_template.md", +} + +const maxPullTemplateBytes = 1 << 20 + +// Template returns the first pull request template found at ref, or "" when the repository has none. +func (s Pulls) Template(ctx context.Context, ref string) (string, error) { + for _, path := range pullTemplatePaths { + file, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (io.ReadCloser, *gitea.Response, error) { + return c.Repositories.GetFileReader(ctx, owner, name, ref, path) + }) + if err != nil { + if apiError, ok := errors.AsType[*transport.APIError](err); ok && apiError.Status == http.StatusNotFound { + continue + } + return "", err + } + data, err := io.ReadAll(io.LimitReader(file, maxPullTemplateBytes)) + _ = file.Close() + return string(data), err + } + return "", nil +} diff --git a/internal/services/pulls_test.go b/internal/services/pulls_test.go new file mode 100644 index 00000000..e228b9ba --- /dev/null +++ b/internal/services/pulls_test.go @@ -0,0 +1,329 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullMilestoneClearUsesIssueEndpoint(t *testing.T) { + for _, failCompletion := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "partial failure"}[failCompletion], func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/owner/repo/issues/1", r.URL.Path) + data, _ := io.ReadAll(r.Body) + cleared, body, unset := int64(0), "", true + assertIssueEdit(t, gitea.EditIssueOption{Milestone: &cleared, Body: &body, RemoveDeadline: &unset}, data) + _, _ = w.Write([]byte("{\"number\":1}")) + return + } + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + if failCompletion { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = w.Write([]byte("{\"number\":1,\"title\":\"Title\"}")) + })) + defer server.Close() + cleared, body := int64(0), "" + clearDeadline := true + item, err := (Pulls{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).Edit(context.Background(), 1, PullEdit{Milestone: &cleared, Body: &body, RemoveDeadline: &clearDeadline}) + if failCompletion { + require.ErrorContains(t, err, "issue fields were updated") + } else { + require.NoError(t, err) + require.EqualValues(t, 1, item.Number) + } + require.Equal(t, 2, calls) + }) + } +} + +func TestFindOpenByHeadPagesPastSmallServerLimitsAndMatchesExactly(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path) + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`[{"number":1,"head":{"ref":"Feature"}}]`)) + case "2": + _, _ = w.Write([]byte(`[{"number":2,"head":{"ref":"feature"}}]`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + service := Pulls{Client: client, Repository: "alice/project"} + item, err := service.FindOpenByHead(context.Background(), "feature") + require.NoError(t, err) + require.EqualValues(t, 2, item.Number) + _, err = service.FindOpenByHead(context.Background(), "FEATURE") + require.ErrorContains(t, err, "no open pull request") +} + +func TestListOpenRejectsEndlessPagination(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path) + _, _ = w.Write([]byte(`[{"number":1,"head":{"ref":"main"}}]`)) + })) + _, err := (Pulls{Client: client, Repository: "alice/project"}).ListOpen(context.Background()) + require.ErrorContains(t, err, "exceeded") +} + +func TestPullFiltersAreEncodedBeforeListing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path) + query := r.URL.Query() + assert.Equal(t, "alice", query.Get("poster")) + assert.Equal(t, "feature/topic", query.Get("base_branch")) + assert.Equal(t, "recentupdate", query.Get("sort")) + assert.Equal(t, "9", query.Get("milestone")) + assert.Equal(t, []string{"10", "11"}, query["labels"]) + assert.Equal(t, "2", query.Get("page")) + _, _ = w.Write([]byte(`[{"number":7,"user":{"login":"alice"},"mergeable":true,"created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00Z"}]`)) + })) + defer server.Close() + service := Pulls{Client: transport.New(server.URL, "token", false), Repository: "alice/project"} + options := PullListOptions{State: "open", Limit: 30, Page: 2, Author: "alice", Base: "feature/topic", Sort: "recentupdate", Milestone: 9, Labels: []int64{10, 11}} + items, _, err := service.List(context.Background(), options) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, "alice", items[0].Author) + require.Equal(t, "2026-10-02T12:00:00Z", items[0].UpdatedAt) + require.True(t, items[0].Mergeable) +} + +func TestPullTemplateLookup(t *testing.T) { + for _, tc := range []struct { + name, want, wantErr string + status map[string]int + requests int + }{ + {"root first", "template", "", map[string]int{"PULL_REQUEST_TEMPLATE.md": http.StatusOK, ".gitea/pull_request_template.md": http.StatusOK}, 1}, + {"uppercase before lowercase", "template", "", map[string]int{".gitea/PULL_REQUEST_TEMPLATE.md": http.StatusOK}, 3}, + {"later match", "template", "", map[string]int{".github/pull_request_template.md": http.StatusOK}, 6}, + {"docs is not searched", "", "", map[string]int{"docs/pull_request_template.md": http.StatusOK}, 6}, + {"none", "", "", nil, 6}, + {"server error", "", "500", map[string]int{".gitea/pull_request_template.md": http.StatusInternalServerError}, 4}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + assert.Equal(t, "release/1.0", r.URL.Query().Get("ref")) + status, ok := tc.status[strings.TrimPrefix(r.URL.Path, "/api/v1/repos/owner/repo/raw/")] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(status) + _, _ = w.Write([]byte("template")) + })) + defer server.Close() + got, err := (Pulls{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).Template(context.Background(), "release/1.0") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.want, got) + require.Equal(t, tc.requests, requests) + }) + } +} + +func TestPullMergeExplainsRefusal(t *testing.T) { + for _, tc := range []struct { + name, pull, want string + }{ + {"conflicting files", `{"state":"open","mergeable":false}`, "pull request #3 has conflicting files or is otherwise not mergeable"}, + {"already merged", `{"state":"closed","merged":true}`, "pull request #3 has already been merged"}, + {"closed", `{"state":"closed"}`, "pull request #3 is closed"}, + {"draft", `{"state":"open","draft":true}`, "pull request #3 is a draft"}, + {"refused while mergeable", `{"state":"open","mergeable":true}`, "Please try again later"}, + } { + t.Run(tc.name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/pulls/3/merge": + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"message":"Please try again later"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/pulls/3": + _, _ = w.Write([]byte(tc.pull)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + err := (Pulls{Client: client, Repository: "owner/repo"}).Merge(context.Background(), 3, MergeOptions{Method: "merge"}) + require.ErrorContains(t, err, tc.want) + var apiErr *transport.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusMethodNotAllowed, apiErr.Status) + }) + } +} + +func TestIssueAndPullListsFillPagesCappedByServer(t *testing.T) { + // The server caps pages at two items (MAX_RESPONSE_ITEMS=2) and holds seven. + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + size := min(limit, 2) + numbers := []string{} + for n := (page-1)*size + 1; n <= min(page*size, 7); n++ { + numbers = append(numbers, `{"number":`+strconv.Itoa(n)+`}`) + } + w.Header().Set("X-Total-Count", "7") + _, _ = w.Write([]byte("[" + strings.Join(numbers, ",") + "]")) + })) + issues, more, err := (Issues{Client: client, Repository: "owner/repo"}).List(context.Background(), ListOptions{State: "open", Limit: 3, Page: 2}) + require.NoError(t, err) + require.Len(t, issues, 3) + require.EqualValues(t, 4, issues[0].Number) + require.True(t, more) + pulls, more, err := (Pulls{Client: client, Repository: "owner/repo"}).List(context.Background(), PullListOptions{State: "open", Limit: 3, Page: 3}) + require.NoError(t, err) + require.Len(t, pulls, 1) + require.EqualValues(t, 7, pulls[0].Number) + require.False(t, more) +} + +func TestPullMergeSubmitsTheCurrentHead(t *testing.T) { + var merged map[string]any + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/pulls/3": + _, _ = w.Write([]byte(`{"number":3,"state":"open","mergeable":true,"head":{"sha":"abc123"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/pulls/3/merge": + assert.NoError(t, json.NewDecoder(r.Body).Decode(&merged)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + err := (Pulls{Client: client, Repository: "owner/repo"}).Merge(context.Background(), 3, MergeOptions{Method: "squash", Title: "Squashed"}) + require.NoError(t, err) + require.Equal(t, "squash", merged["Do"]) + require.Equal(t, "Squashed", merged["MergeTitleField"]) + require.Equal(t, "abc123", merged["head_commit_id"]) + require.Equal(t, false, merged["delete_branch_after_merge"]) +} + +func TestPullMergeStopsWhenThePullIsUnreadable(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusNotFound) + })) + err := (Pulls{Client: client, Repository: "owner/repo"}).Merge(context.Background(), 3, MergeOptions{Method: "merge"}) + var apiErr *transport.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.Status) +} + +func TestPullEditSendsOnlySuppliedPullFields(t *testing.T) { + var edited map[string]any + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/4", r.URL.Path) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&edited)) + _, _ = w.Write([]byte(`{"number":4,"state":"closed","base":{"ref":"release"}}`)) + })) + title, state, base, allow := "Renamed", "closed", "release", false + assignees := []string{} + item, err := (Pulls{Client: client, Repository: "owner/repo"}).Edit(context.Background(), 4, PullEdit{ + Title: &title, State: &state, Base: &base, AllowMaintainerEdit: &allow, Assignees: &assignees, + }) + require.NoError(t, err) + require.Equal(t, "closed", item.State) + require.Equal(t, "release", item.Base) + require.Equal(t, "Renamed", edited["title"]) + require.Equal(t, "closed", edited["state"]) + require.Equal(t, "release", edited["base"]) + require.Equal(t, false, edited["allow_maintainer_edit"]) + require.Equal(t, []any{}, edited["assignees"]) + require.Nil(t, edited["body"]) + require.Nil(t, edited["due_date"]) + require.Nil(t, edited["unset_due_date"]) + require.Nil(t, edited["labels"]) + require.EqualValues(t, 0, edited["milestone"]) +} + +func TestPullCreateSendsMaintainerEdits(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/owner/repo/pulls", r.URL.Path) + created := requestJSON(t, r) + assert.Equal(t, false, created["allow_maintainer_edit"]) + assert.Equal(t, "feature", created["head"]) + _, _ = w.Write([]byte(`{"number":5,"user":{"login":"alice"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main"}}`)) + })) + allow := false + item, err := (Pulls{Client: client, Repository: "owner/repo"}).Create(context.Background(), PullOptions{Title: "T", Base: "main", Head: "feature", AllowMaintainerEdit: &allow}) + require.NoError(t, err) + require.EqualValues(t, 5, item.Number) + require.Equal(t, "alice", item.Author) + require.Equal(t, "feature", item.Head) + require.Equal(t, "abc", item.HeadSHA) +} + +func TestPullDiffReturnsThePatch(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/2.diff", r.URL.Path) + _, _ = w.Write([]byte("diff --git a/x b/x\n")) + })) + diff, err := (Pulls{Client: client, Repository: "owner/repo"}).Diff(context.Background(), 2) + require.NoError(t, err) + defer diff.Close() + data, err := io.ReadAll(diff) + require.NoError(t, err) + require.Equal(t, "diff --git a/x b/x\n", string(data)) +} + +func TestPullFromMapsServerFields(t *testing.T) { + pull := decode[gitea.PullRequest](t, `{ + "id":10,"number":3,"title":"Feature","body":"Adds","state":"open","html_url":"https://gitea.test/alice/project/pulls/3", + "user":{"login":"bob"},"assignees":[{"login":"carol"}],"requested_reviewers":[{"login":"dave"}], + "requested_reviewers_teams":[{"name":"core"}],"labels":[{"id":1,"name":"bug"}],"milestone":{"id":2,"title":"v1"}, + "merged":false,"mergeable":true,"draft":true,"comments":4, + "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","due_date":"2026-11-01T00:00:00Z", + "base":{"ref":"main","repo":{"full_name":"alice/project"}}, + "head":{"label":"bob:feature","ref":"feature","sha":"abc","repo":{"full_name":"bob/project","clone_url":"https://gitea.test/bob/project.git","ssh_url":"git@gitea.test:bob/project.git","permissions":{"push":true}}}}`) + deadline := "2026-11-01T00:00:00Z" + require.Equal(t, Pull{ + Author: "bob", CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00", Assignees: []string{"carol"}, + Milestone: &IssueMilestone{ID: 2, Title: "v1"}, Labels: []Label{{ID: 1, Name: "bug"}}, Deadline: &deadline, + ID: 10, Number: 3, Title: "Feature", Body: "Adds", URL: "https://gitea.test/alice/project/pulls/3", State: "open", + Mergeable: true, Draft: true, Base: "main", Head: "feature", HeadSHA: "abc", HeadRepo: "bob/project", HeadCanPush: true, + HeadCloneURL: "https://gitea.test/bob/project.git", HeadSSHURL: "git@gitea.test:bob/project.git", + Reviewers: []string{"dave"}, ReviewerTeams: []string{"core"}, Comments: 4, + }, pullFrom(pull)) +} + +func TestPullFromFallsBackToBaseForDeletedHeads(t *testing.T) { + pull := decode[gitea.PullRequest](t, `{"number":3,"head":{"label":"bob:feature","ref":"refs/pull/3/head"},"base":{"ref":"main","repo":{"full_name":"alice/project"}}}`) + result := pullFrom(pull) + require.True(t, result.HeadRemoteDeleted) + require.Equal(t, "feature", result.Head) + require.Equal(t, "alice/project", result.HeadRepo) +} + +func TestPullFromToleratesMissingBranches(t *testing.T) { + require.Equal(t, Pull{Number: 1, Assignees: []string{}, Labels: []Label{}, Reviewers: []string{}, ReviewerTeams: []string{}}, + pullFrom(decode[gitea.PullRequest](t, `{"number":1,"head":null,"base":null}`))) +} diff --git a/internal/services/raw.go b/internal/services/raw.go new file mode 100644 index 00000000..1bf8a2db --- /dev/null +++ b/internal/services/raw.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + + "gitea.dev/tea/internal/transport" +) + +var errInvalidJSON = errors.New("Gitea returned an invalid JSON response") + +// getJSON decodes a raw GET response into out. +func getJSON(ctx context.Context, client *transport.Client, endpoint string, out any) error { + _, err := rawJSON(ctx, client, http.MethodGet, endpoint, nil, out) + return err +} + +// sendJSON sends payload as a JSON body, or no body when it is nil, and decodes the response +// into out unless out is nil. +func sendJSON(ctx context.Context, client *transport.Client, method, endpoint string, payload, out any) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + _, err := rawJSON(ctx, client, method, endpoint, body, out) + return err +} + +// rawWindow is listWindow over a raw JSON collection endpoint; it sets limit and page on query. +func rawWindow[T any](ctx context.Context, client *transport.Client, endpoint string, query url.Values, limit, page int) ([]T, bool, error) { + if query == nil { + query = url.Values{} + } + return listWindow(limit, page, func(limit, page int) ([]T, http.Header, error) { + query.Set("limit", strconv.Itoa(limit)) + query.Set("page", strconv.Itoa(page)) + var response []T + headers, err := rawJSON(ctx, client, http.MethodGet, endpoint+"?"+query.Encode(), nil, &response) + return response, headers, err + }) +} + +func rawJSON(ctx context.Context, client *transport.Client, method, endpoint string, body io.Reader, out any) (http.Header, error) { + resp, err := client.Do(ctx, method, endpoint, body, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if out == nil || resp.StatusCode == http.StatusNoContent { + _, err = io.Copy(io.Discard, resp.Body) + return resp.Header, err + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errInvalidJSON + } + return resp.Header, nil +} diff --git a/internal/services/raw_test.go b/internal/services/raw_test.go new file mode 100644 index 00000000..a79edfcf --- /dev/null +++ b/internal/services/raw_test.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRawBodyCancellationPreservesContextError(t *testing.T) { + started := make(chan struct{}) + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"incomplete":`)) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { <-started; cancel() }() + var result any + require.ErrorIs(t, getJSON(ctx, client, "resource", &result), context.Canceled) +} diff --git a/internal/services/releases.go b/internal/services/releases.go new file mode 100644 index 00000000..3017060d --- /dev/null +++ b/internal/services/releases.go @@ -0,0 +1,157 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "io" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Release describes a repository release. +type Release struct { + ID int64 `json:"id"` + Tag string `json:"tag"` + Target string `json:"target"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + CreatedAt string `json:"created_at"` + PublishedAt *string `json:"published_at"` +} + +func releaseFrom(release *gitea.Release) Release { + return Release{ + ID: release.ID, Tag: release.TagName, Target: release.Target, Title: release.Title, Body: release.Note, URL: release.HTMLURL, + Draft: release.IsDraft, Prerelease: release.IsPrerelease, CreatedAt: timestamp(release.CreatedAt), PublishedAt: optionalTimestamp(&release.PublishedAt), + } +} + +// ReleaseOptions distinguishes omitted fields from explicit empty or false values. +type ReleaseOptions struct { + Tag *string `json:"tag_name,omitempty"` + Target *string `json:"target_commitish,omitempty"` + Title *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + Draft *bool `json:"draft,omitempty"` + Prerelease *bool `json:"prerelease,omitempty"` +} + +// Releases implements repository release workflows. +type Releases struct { + Client *transport.Client + Repository string +} + +func (s Releases) endpoint() string { return "repos/" + s.Repository + "/releases" } + +// List returns a non-nil page of releases. +func (s Releases) List(ctx context.Context, limit, page int) ([]Release, error) { + releases, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Release, *gitea.Response, error) { + return c.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(releases, releaseFrom), nil +} + +// View retrieves a release by its tag, including numeric tags. +func (s Releases) View(ctx context.Context, tag string) (Release, error) { + return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) { + return c.Releases.GetReleaseByTag(ctx, owner, repo, tag) + }) +} + +// Create creates a release and lets Gitea create a missing tag at the supplied target. +func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, error) { + return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) { + return c.Releases.CreateRelease(ctx, owner, repo, gitea.CreateReleaseOption{ + TagName: deref(opts.Tag), Target: deref(opts.Target), Title: deref(opts.Title), Note: deref(opts.Body), + IsDraft: deref(opts.Draft), IsPrerelease: deref(opts.Prerelease), + }) + }) +} + +// Edit changes only supplied release fields; Gitea ignores empty text fields. +func (s Releases) Edit(ctx context.Context, id int64, opts ReleaseOptions) (Release, error) { + return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) { + return c.Releases.EditRelease(ctx, owner, repo, id, gitea.EditReleaseOption{ + TagName: deref(opts.Tag), Target: deref(opts.Target), Title: deref(opts.Title), Note: deref(opts.Body), + IsDraft: opts.Draft, IsPrerelease: opts.Prerelease, + }) + }) +} + +// Delete removes a release and optionally its tag; partial failures identify the completed step. +func (s Releases) Delete(ctx context.Context, item Release, deleteTag bool) error { + if err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Releases.DeleteRelease(ctx, owner, repo, item.ID) + }); err != nil { + return err + } + if deleteTag { + if err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Repositories.DeleteTag(ctx, owner, repo, item.Tag) + }); err != nil { + return fmt.Errorf("release deleted, but tag deletion failed: %w", err) + } + } + return nil +} + +// Assets applies CLI pagination to Gitea's unpaginated attachment collection. +func (s Releases) Assets(ctx context.Context, id int64, limit, page int) ([]ReleaseAsset, error) { + items, err := s.AllAssets(ctx, id) + if err != nil { + return nil, err + } + return pageOf(items, limit, page) +} + +// AllAssets retrieves the full collection; this endpoint has no server pagination. +func (s Releases) AllAssets(ctx context.Context, id int64) ([]ReleaseAsset, error) { + attachments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Attachment, *gitea.Response, error) { + return c.Releases.ListReleaseAttachments(ctx, owner, repo, id, gitea.ListReleaseAttachmentsOptions{Page: -1}) + }) + if err != nil { + return nil, err + } + return convertAll(attachments, assetFrom), nil +} + +// DeleteAsset removes one asset from the selected release. +func (s Releases) DeleteAsset(ctx context.Context, id, asset int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Releases.DeleteReleaseAttachment(ctx, owner, repo, id, asset) + }) +} + +// RenameAsset changes only an attachment's display name. +func (s Releases) RenameAsset(ctx context.Context, id, asset int64, name string) (ReleaseAsset, error) { + return repoConvert(ctx, s.Client, s.Repository, assetFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Attachment, *gitea.Response, error) { + return c.Releases.EditReleaseAttachment(ctx, owner, repo, id, asset, gitea.EditAttachmentOptions{Name: name}) + }) +} + +// UploadAsset streams a multipart upload without buffering the asset in memory. +func (s Releases) UploadAsset(ctx context.Context, id int64, name string, input io.Reader) (ReleaseAsset, error) { + return uploadAttachment(ctx, s.Client, fmt.Sprintf("%s/%d/assets", s.endpoint(), id), name, input) +} + +// DownloadAsset opens a repository-validated attachment stream at its direct instance URL. +func (s Releases) DownloadAsset(ctx context.Context, id, asset int64) (io.ReadCloser, error) { + attachment, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Attachment, *gitea.Response, error) { + return c.Releases.GetReleaseAttachment(ctx, owner, repo, id, asset) + }) + if err != nil { + return nil, err + } + return downloadAttachment(ctx, s.Client, attachment) +} diff --git a/internal/services/releases_test.go b/internal/services/releases_test.go new file mode 100644 index 00000000..8765f22c --- /dev/null +++ b/internal/services/releases_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReleaseUploadStreamsAndMapsMetadata(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/alice/project/releases/1/assets", r.URL.Path) + reader, err := r.MultipartReader() + if !assert.NoError(t, err) { + w.WriteHeader(http.StatusBadRequest) + return + } + part, err := reader.NextPart() + if !assert.NoError(t, err) { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Equal(t, "attachment", part.FormName()) + assert.Equal(t, "file.bin", part.FileName()) + content, err := io.ReadAll(part) + assert.NoError(t, err) + assert.Equal(t, "asset bytes", string(content)) + _, err = reader.NextPart() + assert.ErrorIs(t, err, io.EOF) + _, _ = w.Write([]byte(`{"id":2,"name":"file.bin","size":11,"browser_download_url":"https://example.test/file"}`)) + })) + service := Releases{Client: client, Repository: "alice/project"} + asset, err := service.UploadAsset(context.Background(), 1, "file.bin", strings.NewReader("asset bytes")) + require.NoError(t, err) + require.Equal(t, int64(2), asset.ID) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = service.UploadAsset(ctx, 1, "file.bin", strings.NewReader("asset bytes")) + require.ErrorIs(t, err, context.Canceled) +} + +func TestReleaseAssetPaginationUsesOneUnpaginatedRequest(t *testing.T) { + requests := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + assert.Equal(t, "/api/v1/repos/owner/repo/releases/1/assets", r.URL.Path) + _, _ = w.Write([]byte(`[{"id":1},{"id":2},{"id":3}]`)) + })) + service := Releases{Client: client, Repository: "owner/repo"} + items, err := service.AllAssets(context.Background(), 1) + require.NoError(t, err) + require.Len(t, items, 3) + require.Equal(t, 1, requests) + page, err := service.Assets(context.Background(), 1, 2, 2) + require.NoError(t, err) + require.Len(t, page, 1) + require.EqualValues(t, 3, page[0].ID) + page, err = service.Assets(context.Background(), 1, 100, int(^uint(0)>>1)) + require.NoError(t, err) + require.Empty(t, page) + require.NotNil(t, page) +} + +func TestReleaseAssetDownloadFollowsObjectStorageRedirect(t *testing.T) { + storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + _, _ = w.Write([]byte("asset bytes")) + })) + defer storage.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/attachments/uuid" { + http.Redirect(w, r, storage.URL+"/bucket/uuid?X-Amz-Signature=value", http.StatusSeeOther) + return + } + assert.Equal(t, "/api/v1/repos/alice/project/releases/1/assets/2", r.URL.Path) + _, _ = w.Write([]byte(`{"uuid":"uuid"}`)) + })) + defer server.Close() + service := Releases{Client: transport.New(server.URL, "secret", false), Repository: "alice/project"} + body, err := service.DownloadAsset(context.Background(), 1, 2) + require.NoError(t, err) + defer body.Close() + data, err := io.ReadAll(body) + require.NoError(t, err) + require.Equal(t, "asset bytes", string(data)) +} + +func TestReleaseAssetDownloadRejectsUnsafeIdentifiers(t *testing.T) { + for _, uuid := range []string{"", "../secret", "a/b", `a\b`, "a.b"} { + t.Run(uuid, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/releases/1/assets/2", r.URL.Path, "download followed an unsafe identifier") + _ = json.NewEncoder(w).Encode(map[string]string{"uuid": uuid}) + })) + _, err := (Releases{Client: client, Repository: "alice/project"}).DownloadAsset(context.Background(), 1, 2) + require.ErrorContains(t, err, "invalid attachment identifier") + }) + } +} + +func TestReleaseDeleteReportsCompletedStep(t *testing.T) { + for name, test := range map[string]struct { + deleteTag bool + failRelease, failTag bool + wantCalls []string + wantErr string + }{ + "release only": {wantCalls: []string{"/releases/5"}}, + "release and tag": {deleteTag: true, wantCalls: []string{"/releases/5", "/tags/v1%2Fbeta"}}, + "release fails": {deleteTag: true, failRelease: true, wantCalls: []string{"/releases/5"}, wantErr: "unavailable"}, + "tag fails": {deleteTag: true, failTag: true, wantCalls: []string{"/releases/5", "/tags/v1%2Fbeta"}, wantErr: "release deleted, but tag deletion failed"}, + } { + t.Run(name, func(t *testing.T) { + var calls []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + path := strings.TrimPrefix(r.URL.EscapedPath(), "/api/v1/repos/alice/project") + calls = append(calls, path) + if (test.failRelease && strings.HasPrefix(path, "/releases/")) || (test.failTag && strings.HasPrefix(path, "/tags/")) { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + })) + err := (Releases{Client: client, Repository: "alice/project"}).Delete(context.Background(), Release{ID: 5, Tag: "v1/beta"}, test.deleteTag) + if test.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, test.wantErr) + } + if test.failRelease { + require.NotContains(t, err.Error(), "release deleted") + } + require.Equal(t, test.wantCalls, calls) + }) + } +} + +func TestReleaseFromMapsServerFields(t *testing.T) { + release := decode[gitea.Release](t, `{"id":3,"tag_name":"v1","target_commitish":"main","name":"One","body":"Notes", + "url":"https://gitea.test/api/v1/repos/alice/project/releases/3","html_url":"https://gitea.test/alice/project/releases/tag/v1", + "draft":true,"prerelease":true,"created_at":"2026-10-01T12:00:00+02:00","published_at":"2026-10-02T12:00:00Z","author":null}`) + published := "2026-10-02T12:00:00Z" + require.Equal(t, Release{ + ID: 3, Tag: "v1", Target: "main", Title: "One", Body: "Notes", URL: "https://gitea.test/alice/project/releases/tag/v1", + Draft: true, Prerelease: true, CreatedAt: "2026-10-01T12:00:00+02:00", PublishedAt: &published, + }, releaseFrom(release)) +} + +func TestReleaseListAndViewEscapeTags(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/alice/project/releases" { + assert.Equal(t, "10", r.URL.Query().Get("limit")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`[{"id":1,"tag_name":"v1"}]`)) + return + } + assert.Equal(t, "/api/v1/repos/alice/project/releases/tags/v1%2Fbeta%20one", r.URL.EscapedPath()) + _, _ = w.Write([]byte(`{"id":2,"tag_name":"v1/beta one"}`)) + })) + service := Releases{Client: client, Repository: "alice/project"} + items, err := service.List(context.Background(), 10, 2) + require.NoError(t, err) + require.Equal(t, []Release{{ID: 1, Tag: "v1"}}, items) + item, err := service.View(context.Background(), "v1/beta one") + require.NoError(t, err) + require.Equal(t, "v1/beta one", item.Tag) +} + +func TestReleaseCreateAndEditPayloads(t *testing.T) { + var payloads []map[string]any + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload := requestJSON(t, r) + payloads = append(payloads, payload) + _, _ = w.Write([]byte(`{"id":7,"tag_name":"v1","name":"v1"}`)) + })) + service := Releases{Client: client, Repository: "alice/project"} + tag, title, body, yes := "v1", "Release one", "Notes", true + _, err := service.Create(context.Background(), ReleaseOptions{Tag: &tag, Title: &title, Body: &body, Prerelease: &yes}) + require.NoError(t, err) + _, err = service.Edit(context.Background(), 7, ReleaseOptions{Title: &title, Draft: &yes}) + require.NoError(t, err) + require.Len(t, payloads, 2) + require.Equal(t, "v1", payloads[0]["tag_name"]) + require.Equal(t, "Release one", payloads[0]["name"]) + require.Equal(t, "Notes", payloads[0]["body"]) + require.Equal(t, false, payloads[0]["draft"]) + require.Equal(t, true, payloads[0]["prerelease"]) + require.Equal(t, "Release one", payloads[1]["name"]) + require.Equal(t, true, payloads[1]["draft"]) + require.Nil(t, payloads[1]["prerelease"]) + for _, field := range []string{"tag_name", "target_commitish", "body"} { + require.Empty(t, payloads[1][field], "Gitea ignores empty edit fields") + } +} + +func TestReleaseCreateRequiresTitle(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL.Path) + })) + tag, title := "v1", " " + _, err := (Releases{Client: client, Repository: "alice/project"}).Create(context.Background(), ReleaseOptions{Tag: &tag, Title: &title}) + require.ErrorContains(t, err, "title is empty") +} + +func TestReleaseAssetRenameAndDelete(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/releases/1/assets/2", r.URL.Path) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + assert.Equal(t, http.MethodPatch, r.Method) + var payload map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, map[string]string{"name": "renamed.bin"}, payload) + _, _ = w.Write([]byte(`{"id":2,"name":"renamed.bin","created_at":"0001-01-01T00:00:00Z"}`)) + })) + service := Releases{Client: client, Repository: "alice/project"} + asset, err := service.RenameAsset(context.Background(), 1, 2, "renamed.bin") + require.NoError(t, err) + require.Equal(t, ReleaseAsset{ID: 2, Name: "renamed.bin"}, asset) + require.NoError(t, service.DeleteAsset(context.Background(), 1, 2)) +} diff --git a/internal/services/repositories.go b/internal/services/repositories.go new file mode 100644 index 00000000..d1309b95 --- /dev/null +++ b/internal/services/repositories.go @@ -0,0 +1,155 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Repositories implements Gitea repository operations. +type Repositories struct{ Client *transport.Client } + +// Repository is the stable command representation of a repository. +type Repository struct { + ActionsEnabled bool `json:"actions_enabled"` + Website string `json:"website"` + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + URL string `json:"url"` + Private bool `json:"private"` + DefaultBranch string `json:"default_branch"` + CloneURL string `json:"clone_url"` + SSHURL string `json:"ssh_url"` + Archived bool `json:"archived"` + Template bool `json:"template"` + Fork bool `json:"fork"` + Mirror bool `json:"mirror"` + Parent *Repository `json:"parent,omitempty"` + // OriginalURL stays out of JSON output: migration sources may embed credentials. + OriginalURL string `json:"-"` +} + +// RepositoryOptions supplies repository creation input. +type RepositoryOptions struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` + DefaultBranch string `json:"default_branch,omitempty"` + Labels string `json:"issue_labels,omitempty"` + Gitignores string `json:"gitignores,omitempty"` + License string `json:"license,omitempty"` + Readme string `json:"readme,omitempty"` + Template bool `json:"template"` + TrustModel string `json:"trust_model,omitempty"` + ObjectFormat string `json:"object_format_name,omitempty"` +} + +// RepositoryEdit distinguishes omitted settings from explicit false values. +type RepositoryEdit struct { + ActionsEnabled *bool `json:"has_actions,omitempty"` + Name *string `json:"name,omitempty"` + Website *string `json:"website,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Archived *bool `json:"archived,omitempty"` + Template *bool `json:"template,omitempty"` +} + +// View retrieves one repository. +func (s Repositories) View(ctx context.Context, repo string) (Repository, error) { + item, err := s.get(ctx, repo) + if err != nil { + return Repository{}, err + } + return repositoryFrom(item), nil +} + +func (s Repositories) get(ctx context.Context, repo string) (*gitea.Repository, error) { + return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) { + return c.Repositories.GetRepo(ctx, owner, name) + }) +} + +// RepositoryPermissions holds server-side access flags used only for local decisions. +// It is intentionally omitted from the stable Repository JSON schema. +type RepositoryPermissions struct { + Admin bool `json:"admin"` + Push bool `json:"push"` + Pull bool `json:"pull"` +} + +// viewPermissions reads repository access flags without exposing them on Repository results. +func (s Repositories) viewPermissions(ctx context.Context, repo string) (RepositoryPermissions, error) { + item, err := s.get(ctx, repo) + if err != nil || item.Permissions == nil { + return RepositoryPermissions{}, err + } + return RepositoryPermissions{Admin: item.Permissions.Admin, Push: item.Permissions.Push, Pull: item.Permissions.Pull}, nil +} + +// assignees lists usernames that can be assigned on repository issues. +func (s Repositories) assignees(ctx context.Context, repo string) ([]string, error) { + users, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.User, *gitea.Response, error) { + return c.Repositories.GetAssignees(ctx, owner, name) + }) + if err != nil { + return nil, err + } + return convertAll(users, login), nil +} + +// Create creates a repository for the account or the named organization. +func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOptions) (Repository, error) { + options := gitea.CreateRepoOption{ + Name: opts.Name, Description: opts.Description, Private: opts.Private, IssueLabels: opts.Labels, + AutoInit: opts.AutoInit, Template: opts.Template, Gitignores: opts.Gitignores, License: opts.License, + Readme: opts.Readme, DefaultBranch: opts.DefaultBranch, TrustModel: gitea.TrustModel(opts.TrustModel), + ObjectFormatName: opts.ObjectFormat, + } + item, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Repository, *gitea.Response, error) { + if org != "" { + return c.Repositories.CreateOrgRepo(ctx, org, options) + } + return c.Repositories.CreateRepo(ctx, options) + }) + if err != nil { + return Repository{}, err + } + return repositoryFrom(item), nil +} + +// Edit updates only explicitly supplied resource fields. +func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) { + return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) { + return c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{ + HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch, + Description: opts.Description, Private: opts.Private, Archived: opts.Archived, Template: opts.Template, + }) + }) +} + +// Delete removes the selected repository. +func (s Repositories) Delete(ctx context.Context, repo string) error { + return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + return c.Repositories.DeleteRepo(ctx, owner, name) + }) +} + +// Fork creates a fork for the current user or selected organization. +func (s Repositories) Fork(ctx context.Context, repo, org string) (Repository, error) { + var options gitea.CreateForkOption + if org != "" { + options.Organization = &org + } + return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) { + return c.Repositories.CreateFork(ctx, owner, name, options) + }) +} diff --git a/internal/services/repositories_test.go b/internal/services/repositories_test.go new file mode 100644 index 00000000..d73c7990 --- /dev/null +++ b/internal/services/repositories_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryCreateSelectsOwnerEndpoint(t *testing.T) { + for org, path := range map[string]string{"": "/api/v1/user/repos", "acme": "/api/v1/orgs/acme/repos"} { + t.Run(path, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, path, r.URL.Path) + payload := requestJSON(t, r) + assert.Equal(t, "project", payload["name"]) + assert.Equal(t, true, payload["private"]) + assert.Equal(t, "Default", payload["issue_labels"]) + assert.Equal(t, "sha256", payload["object_format_name"]) + assert.Equal(t, "collaborator", payload["trust_model"]) + _, _ = w.Write([]byte(`{"id":1,"name":"project","full_name":"owner/project"}`)) + })) + item, err := (Repositories{Client: client}).Create(context.Background(), org, RepositoryOptions{ + Name: "project", Private: true, Labels: "Default", ObjectFormat: "sha256", TrustModel: "collaborator", + }) + require.NoError(t, err) + require.Equal(t, Repository{ID: 1, Name: "project", FullName: "owner/project"}, item) + }) + } +} + +func TestRepositoryForkSendsOrganizationOnlyWhenSelected(t *testing.T) { + for org, want := range map[string]any{"": nil, "acme": "acme"} { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/forks", r.URL.Path) + payload := requestJSON(t, r) + assert.Equal(t, want, payload["organization"]) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"id":2,"full_name":"bob/project","fork":true,"parent":{"id":1,"full_name":"alice/project"}}`)) + })) + item, err := (Repositories{Client: client}).Fork(context.Background(), "alice/project", org) + require.NoError(t, err) + require.Equal(t, "alice/project", item.Parent.FullName) + } +} + +func TestRepositoryAssigneesAndMissingPermissions(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/alice/project/assignees" { + _, _ = w.Write([]byte(`[{"login":"alice"},{"login":"bob"}]`)) + return + } + _, _ = w.Write([]byte(`{"id":1}`)) + })) + service := Repositories{Client: client} + names, err := service.assignees(context.Background(), "alice/project") + require.NoError(t, err) + require.Equal(t, []string{"alice", "bob"}, names) + permissions, err := service.viewPermissions(context.Background(), "alice/project") + require.NoError(t, err) + require.Equal(t, RepositoryPermissions{}, permissions) +} diff --git a/internal/services/repository_search.go b/internal/services/repository_search.go new file mode 100644 index 00000000..17b683fb --- /dev/null +++ b/internal/services/repository_search.go @@ -0,0 +1,130 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + + gitea "gitea.dev/sdk" +) + +// RepositoryListOptions selects a repository collection and page. +type RepositoryListOptions struct { + Owner, Kind string + Starred, Watched bool + Limit, Page int +} + +// RepositorySearchOptions supplies explicit repository search filters. +type RepositorySearchOptions struct { + Query, Owner, Kind, Sort, Order string + Topic bool + Private, Archived *bool + Limit, Page int +} + +func (s Repositories) ownerID(ctx context.Context, owner string) (int64, bool, error) { + if id, isOrg, err := organization(ctx, s.Client, owner); err != nil || isOrg { + return id, isOrg, err + } + user, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetUserInfo(ctx, owner) + }) + if err != nil { + return 0, false, err + } + return user.ID, false, nil +} + +func repositoryKind(item Repository, kind string) bool { + switch kind { + case "fork": + return item.Fork + case "mirror": + return item.Mirror + case "source": + return !item.Fork && !item.Mirror + default: + return true + } +} + +// List returns repositories accessible to the account, or its selected collection. +// It reports whether more repositories follow the selected page. +func (s Repositories) List(ctx context.Context, opts RepositoryListOptions) ([]Repository, bool, error) { + isOrg := false + var err error + if opts.Owner != "" { + if _, isOrg, err = s.ownerID(ctx, opts.Owner); err != nil { + return nil, false, err + } + } + var items []*gitea.Repository + var more bool + if opts.Owner == "" && (opts.Starred || opts.Watched) { + // TODO(sdk): GetMyStarredRepos and GetMyWatchedRepos accept no ListOptions. + endpoint := "user/subscriptions" + if opts.Starred { + endpoint = "user/starred" + } + items, more, err = rawWindow[*gitea.Repository](ctx, s.Client, endpoint, nil, opts.Limit, opts.Page) + } else { + items, more, err = listWindow(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Repository, http.Header, error) { + items, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { + switch { + case isOrg: + return c.Repositories.ListOrgRepos(ctx, opts.Owner, gitea.ListOrgReposOptions{ListOptions: listOptions(limit, page)}) + case opts.Owner != "": + return c.Repositories.ListUserRepos(ctx, opts.Owner, gitea.ListReposOptions{ListOptions: listOptions(limit, page)}) + default: + return c.Repositories.ListMyRepos(ctx, gitea.ListReposOptions{ListOptions: listOptions(limit, page)}) + } + }) + return items, headerOf(resp), err + }) + } + if err != nil { + return nil, false, err + } + result := make([]Repository, 0, len(items)) + for _, item := range items { + if repo := repositoryFrom(item); repositoryKind(repo, opts.Kind) { + result = append(result, repo) + } + } + return result, more, nil +} + +// Search searches repository names, descriptions or topics within one instance. +func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions) ([]Repository, error) { + options := gitea.SearchRepoOptions{ + ListOptions: listOptions(opts.Limit, opts.Page), Keyword: opts.Query, KeywordIsTopic: opts.Topic, + KeywordInDescription: true, IsPrivate: opts.Private, IsArchived: opts.Archived, Type: gitea.RepoType(opts.Kind), + } + if opts.Sort != "" { + options.Sort, options.Order = opts.Sort, opts.Order + } + if opts.Owner != "" { + id, _, err := s.ownerID(ctx, opts.Owner) + if err != nil { + return nil, err + } + options.OwnerID = id + } + account, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetMyUserInfo(ctx) + }) + if err != nil { + return nil, err + } + options.PrioritizedByOwnerID = account.ID + items, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { + return c.Repositories.SearchRepos(ctx, options) + }) + if err != nil { + return nil, err + } + return convertAll(items, repositoryFrom), nil +} diff --git a/internal/services/repository_search_test.go b/internal/services/repository_search_test.go new file mode 100644 index 00000000..dc1bc409 --- /dev/null +++ b/internal/services/repository_search_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryOwnerLookupOnlyFallsBackOnNotFound(t *testing.T) { + for _, status := range []int{http.StatusForbidden, http.StatusNotFound} { + t.Run(http.StatusText(status), func(t *testing.T) { + calls := []string{} + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path) + if r.URL.Path == "/api/v1/orgs/owner" { + w.WriteHeader(status) + return + } + if r.URL.Path == "/api/v1/users/owner" { + _, _ = w.Write([]byte(`{"id":2}`)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + items, _, err := (Repositories{Client: client}).List(context.Background(), RepositoryListOptions{Owner: "owner", Limit: 30, Page: 1}) + if status == http.StatusForbidden { + require.Error(t, err) + require.Equal(t, []string{"/api/v1/orgs/owner"}, calls) + } else { + require.NoError(t, err) + require.NotNil(t, items) + require.Equal(t, []string{"/api/v1/orgs/owner", "/api/v1/users/owner", "/api/v1/users/owner/repos"}, calls) + } + }) + } +} + +func TestRepositoryListSelectsCollection(t *testing.T) { + for name, test := range map[string]struct { + opts RepositoryListOptions + path string + }{ + "account": {RepositoryListOptions{}, "/api/v1/user/repos"}, + "user": {RepositoryListOptions{Owner: "bob"}, "/api/v1/users/bob/repos"}, + "organization": {RepositoryListOptions{Owner: "acme"}, "/api/v1/orgs/acme/repos"}, + "starred": {RepositoryListOptions{Starred: true}, "/api/v1/user/starred"}, + "watched": {RepositoryListOptions{Watched: true}, "/api/v1/user/subscriptions"}, + "owner first": {RepositoryListOptions{Owner: "bob", Starred: true}, "/api/v1/users/bob/repos"}, + } { + t.Run(name, func(t *testing.T) { + var listed []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/orgs/acme": + _, _ = w.Write([]byte(`{"id":3,"username":"acme"}`)) + case "/api/v1/orgs/bob": + w.WriteHeader(http.StatusNotFound) + case "/api/v1/users/bob": + _, _ = w.Write([]byte(`{"id":2,"login":"bob"}`)) + default: + listed = append(listed, r.URL.Path) + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "1", r.URL.Query().Get("limit")) + w.Header().Set("X-Total-Count", "3") + _, _ = w.Write([]byte(`[{"id":2,"name":"second","fork":true}]`)) + } + })) + test.opts.Limit, test.opts.Page = 1, 2 + items, more, err := (Repositories{Client: client}).List(context.Background(), test.opts) + require.NoError(t, err) + require.Equal(t, []string{test.path}, listed) + require.Equal(t, []Repository{{ID: 2, Name: "second", Fork: true}}, items) + require.True(t, more) + }) + } +} + +func TestRepositoryListFiltersKind(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"id":1,"fork":true},{"id":2,"mirror":true},{"id":3}]`)) + })) + for kind, want := range map[string]int64{"fork": 1, "mirror": 2, "source": 3} { + items, _, err := (Repositories{Client: client}).List(context.Background(), RepositoryListOptions{Kind: kind, Limit: 30, Page: 1}) + require.NoError(t, err) + require.Len(t, items, 1, kind) + require.Equal(t, want, items[0].ID, kind) + } +} + +func TestRepositorySearchSendsExplicitFalseFilters(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/user" { + _, _ = w.Write([]byte(`{"id":7}`)) + return + } + assert.Equal(t, "/api/v1/repos/search", r.URL.Path) + query := r.URL.Query() + assert.Equal(t, "false", query.Get("is_private")) + assert.Equal(t, "false", query.Get("archived")) + assert.Equal(t, "true", query.Get("topic")) + assert.Equal(t, "7", query.Get("priority_owner_id")) + assert.Equal(t, "two words", query.Get("q")) + assert.Equal(t, "true", query.Get("includeDesc")) + assert.Equal(t, "30", query.Get("limit")) + assert.Equal(t, "1", query.Get("page")) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + value := false + items, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Query: "two words", Private: &value, Archived: &value, Topic: true, Limit: 30, Page: 1}) + require.NoError(t, err) + require.NotNil(t, items) + require.Empty(t, items) +} + +func TestRepositorySearchScopesOwner(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/orgs/acme": + _, _ = w.Write([]byte(`{"id":3,"username":"acme"}`)) + case "/api/v1/user": + _, _ = w.Write([]byte(`{"id":7,"login":"alice"}`)) + default: + query := r.URL.Query() + assert.Equal(t, "3", query.Get("uid")) + assert.Equal(t, "true", query.Get("exclusive")) + assert.Equal(t, "fork", query.Get("mode")) + assert.Equal(t, "updated", query.Get("sort")) + assert.Equal(t, "desc", query.Get("order")) + assert.False(t, query.Has("is_private")) + _, _ = w.Write([]byte(`{"data":[{"id":4,"full_name":"acme/tool"}]}`)) + } + })) + items, err := (Repositories{Client: client}).Search(context.Background(), RepositorySearchOptions{Owner: "acme", Kind: "fork", Sort: "updated", Order: "desc", Limit: 30, Page: 1}) + require.NoError(t, err) + require.Equal(t, []Repository{{ID: 4, FullName: "acme/tool"}}, items) +} diff --git a/internal/services/repository_setup.go b/internal/services/repository_setup.go new file mode 100644 index 00000000..d0d88b82 --- /dev/null +++ b/internal/services/repository_setup.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "net/http" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// RepositoryTemplateOptions controls copying from a template repository. +type RepositoryTemplateOptions struct { + Name string `json:"name"` + Owner string `json:"owner"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch,omitempty"` + Private bool `json:"private"` + Content bool `json:"git_content"` + GitHooks bool `json:"git_hooks"` + Avatar bool `json:"avatar"` + Labels bool `json:"labels"` + Topics bool `json:"topics"` + Webhooks bool `json:"webhooks"` +} + +// Generate creates a repository from a template on the selected instance. +func (s Repositories) Generate(ctx context.Context, source string, opts RepositoryTemplateOptions) (Repository, error) { + if opts.Owner == "" { + account, err := CurrentAccount(ctx, s.Client) + if err != nil { + return Repository{}, err + } + opts.Owner = account + } + // TODO(sdk): CreateRepoFromTemplateOption has no default_branch. + var response gitea.Repository + if err := sendJSON(ctx, s.Client, http.MethodPost, "repos/"+source+"/generate", opts, &response); err != nil { + return Repository{}, err + } + return repositoryFrom(&response), nil +} + +// RepositoryMigrationOptions is explicit server-side import input. Credentials stay in request bodies. +type RepositoryMigrationOptions struct { + Name string `json:"repo_name"` + Owner string `json:"repo_owner,omitempty"` + CloneURL string `json:"clone_addr"` + Service string `json:"service"` + AuthUser string `json:"auth_username,omitempty"` + AuthPassword string `json:"auth_password,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + Mirror bool `json:"mirror"` + Private bool `json:"private"` + Description string `json:"description,omitempty"` + Wiki bool `json:"wiki"` + Milestones bool `json:"milestones"` + Labels bool `json:"labels"` + Issues bool `json:"issues"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` + MirrorInterval string `json:"mirror_interval,omitempty"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint,omitempty"` + Timeout time.Duration `json:"-"` +} + +// Migrate imports a remote repository through Gitea's migration API. +func (s Repositories) Migrate(ctx context.Context, opts RepositoryMigrationOptions) (Repository, error) { + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + s.Client = s.Client.WithTimeout(opts.Timeout) + } + item, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Repository, *gitea.Response, error) { + return c.Repositories.MigrateRepo(ctx, gitea.MigrateRepoOption{ + RepoName: opts.Name, RepoOwner: opts.Owner, CloneAddr: opts.CloneURL, Service: gitea.GitServiceType(opts.Service), + AuthUsername: opts.AuthUser, AuthPassword: opts.AuthPassword, AuthToken: opts.AuthToken, Mirror: opts.Mirror, + Private: opts.Private, Description: opts.Description, Wiki: opts.Wiki, Milestones: opts.Milestones, + Labels: opts.Labels, Issues: opts.Issues, PullRequests: opts.PullRequests, Releases: opts.Releases, + MirrorInterval: opts.MirrorInterval, LFS: opts.LFS, LFSEndpoint: opts.LFSEndpoint, + }) + }) + if err != nil { + // The server may echo source credentials in its error message. + if failure, ok := errors.AsType[*transport.APIError](err); ok && (opts.AuthPassword != "" || opts.AuthToken != "") { + failure.Message = "" + } + return Repository{}, err + } + return repositoryFrom(item), nil +} diff --git a/internal/services/repository_setup_test.go b/internal/services/repository_setup_test.go new file mode 100644 index 00000000..9280f23c --- /dev/null +++ b/internal/services/repository_setup_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryTemplateUsesAuthenticatedDestination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + require.Equal(t, "/api/v1/user", r.URL.Path) + _, _ = w.Write([]byte(`{"login":"account"}`)) + return + } + require.Equal(t, "/api/v1/repos/owner/template/generate", r.URL.Path) + payload := requestJSON(t, r) + require.Equal(t, "account", payload["owner"]) + require.Equal(t, true, payload["git_content"]) + require.Equal(t, false, payload["git_hooks"]) + _, _ = w.Write([]byte(`{"id":2,"name":"generated"}`)) + })) + defer server.Close() + item, err := (Repositories{Client: transport.New(server.URL, "", false)}).Generate(context.Background(), "owner/template", RepositoryTemplateOptions{Name: "generated", Content: true}) + require.NoError(t, err) + require.Equal(t, "generated", item.Name) +} + +func TestRepositoryMigrationDoesNotRetryOrLeakCredentials(t *testing.T) { + mutations := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutations++ + require.Equal(t, "/api/v1/repos/migrate", r.URL.Path) + payload := requestJSON(t, r) + require.Equal(t, "source-secret", payload["auth_password"]) + require.NotContains(t, payload, "Timeout") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"source-secret"}`)) + })) + defer server.Close() + client := transport.New(server.URL, "destination-token", false) + originalTimeout := client.HTTP.Timeout + _, err := (Repositories{Client: client}).Migrate(context.Background(), RepositoryMigrationOptions{Name: "imported", CloneURL: "https://source.test/repo", Service: "git", AuthUser: "source-account", AuthPassword: "source-secret", Timeout: time.Minute}) + require.Error(t, err) + require.NotContains(t, err.Error(), "source-secret") + require.Equal(t, 1, mutations) + require.Equal(t, originalTimeout, client.HTTP.Timeout) +} + +func TestRepositoryMigrationMapsOptionsAndClearsTokenEcho(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/migrate", r.URL.Path) + payload := requestJSON(t, r) + assert.Equal(t, "imported", payload["repo_name"]) + assert.Equal(t, "acme", payload["repo_owner"]) + assert.Equal(t, "https://source.test/repo", payload["clone_addr"]) + assert.Equal(t, "github", payload["service"]) + assert.Equal(t, "source-token", payload["auth_token"]) + assert.Equal(t, true, payload["issues"]) + assert.Equal(t, true, payload["lfs"]) + assert.Equal(t, "https://source.test/lfs", payload["lfs_endpoint"]) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"bad credential source-token"}`)) + })) + _, err := (Repositories{Client: client}).Migrate(context.Background(), RepositoryMigrationOptions{ + Name: "imported", Owner: "acme", CloneURL: "https://source.test/repo", Service: "github", AuthToken: "source-token", + Issues: true, LFS: true, LFSEndpoint: "https://source.test/lfs", + }) + var failure *transport.APIError + require.ErrorAs(t, err, &failure) + require.Equal(t, http.StatusUnprocessableEntity, failure.Status) + require.NotContains(t, err.Error(), "source-token") +} + +func TestRepositoryMigrationKeepsMessageWithoutCredentials(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":"repository already exists"}`)) + })) + _, err := (Repositories{Client: client}).Migrate(context.Background(), RepositoryMigrationOptions{Name: "imported", CloneURL: "https://source.test/repo", Service: "git"}) + require.ErrorContains(t, err, "repository already exists") +} diff --git a/internal/services/review_requests.go b/internal/services/review_requests.go new file mode 100644 index 00000000..b530549c --- /dev/null +++ b/internal/services/review_requests.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "strings" + + gitea "gitea.dev/sdk" +) + +// ReviewRequestInput names users and teams within the repository's organization. +type ReviewRequestInput struct { + Users []string `json:"reviewers,omitempty"` + Teams []string `json:"team_reviewers,omitempty"` +} + +// ReviewRequestChanges adds and removes explicitly named review requests. +type ReviewRequestChanges struct { + Add, Remove ReviewRequestInput +} + +// Validate rejects empty, duplicated, and conflicting names before any request. +func (c ReviewRequestChanges) Validate() error { + if len(c.Add.Users)+len(c.Add.Teams)+len(c.Remove.Users)+len(c.Remove.Teams) == 0 { + return errors.New("supply at least one reviewer or team") + } + for _, pair := range []struct { + add, remove []string + noun string + }{{c.Add.Users, c.Remove.Users, "reviewer"}, {c.Add.Teams, c.Remove.Teams, "team"}} { + seen := map[string]bool{} + for _, names := range [][]string{pair.add, pair.remove} { + for _, name := range names { + if strings.TrimSpace(name) == "" || name != strings.TrimSpace(name) { + return fmt.Errorf("%s names cannot be empty or have surrounding whitespace", pair.noun) + } + key := strings.ToLower(name) + if seen[key] { + return fmt.Errorf("%s %q is duplicated or present in both add and remove", pair.noun, name) + } + seen[key] = true + } + } + } + return nil +} + +// ChangeRequests returns the PR after changes and reports partial mutation failures. +func (s Reviews) ChangeRequests(ctx context.Context, n int64, changes ReviewRequestChanges) (Pull, error) { + if err := changes.Validate(); err != nil { + return Pull{}, err + } + if len(changes.Add.Teams)+len(changes.Remove.Teams) > 0 { + if err := s.CheckRequestTeams(ctx, append(append([]string{}, changes.Add.Teams...), changes.Remove.Teams...)); err != nil { + return Pull{}, err + } + } + mutated := false + for _, action := range []struct { + remove bool + input ReviewRequestInput + }{{true, changes.Remove}, {false, changes.Add}} { + if len(action.input.Users)+len(action.input.Teams) == 0 { + continue + } + option := gitea.PullReviewRequestOptions{Reviewers: action.input.Users, TeamReviewers: action.input.Teams} + err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + if action.remove { + return c.PullRequests.DeleteReviewRequests(ctx, owner, name, n, option) + } + return c.PullRequests.CreateReviewRequests(ctx, owner, name, n, option) + }) + if err != nil { + if mutated { + return Pull{}, fmt.Errorf("reviewer removals succeeded, but adding reviewers failed: %w", err) + } + return Pull{}, fmt.Errorf("review request update failed; Gitea may have applied some requests: %w", err) + } + mutated = true + } + item, err := (Pulls{Client: s.Client, Repository: s.Repository}).View(ctx, n) + if err != nil { + return Pull{}, fmt.Errorf("review requests were updated, but reading the pull request failed: %w", err) + } + return item, nil +} + +// CheckRequestTeams verifies that every named team exists in the repository's organization. +func (s Reviews) CheckRequestTeams(ctx context.Context, names []string) error { + owner, _, _ := strings.Cut(s.Repository, "/") + _, isOrg, err := organization(ctx, s.Client, owner) + if err != nil { + return err + } + if !isOrg { + return errors.New("team reviews require an organization repository") + } + teams, err := (Teams{Client: s.Client}).ByName(ctx, owner) + if err != nil { + return err + } + for _, name := range names { + if _, ok := teams[strings.ToLower(name)]; !ok { + return fmt.Errorf("team %q is unavailable in organization %s", name, owner) + } + } + return nil +} diff --git a/internal/services/review_requests_test.go b/internal/services/review_requests_test.go new file mode 100644 index 00000000..da7ae3e6 --- /dev/null +++ b/internal/services/review_requests_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReviewRequestNamesValidatedBeforeNetwork(t *testing.T) { + for _, changes := range []ReviewRequestChanges{ + {}, + {Add: ReviewRequestInput{Users: []string{""}}}, + {Add: ReviewRequestInput{Teams: []string{" team"}}}, + {Add: ReviewRequestInput{Users: []string{"Alice", "alice"}}}, + {Add: ReviewRequestInput{Users: []string{"Alice"}}, Remove: ReviewRequestInput{Users: []string{"alice"}}}, + {Add: ReviewRequestInput{Teams: []string{"Team"}}, Remove: ReviewRequestInput{Teams: []string{"team"}}}, + } { + require.Error(t, changes.Validate()) + } + require.NoError(t, (ReviewRequestChanges{Add: ReviewRequestInput{Users: []string{"alice@example.test"}, Teams: []string{"alice"}}}).Validate()) +} + +func TestReviewRequestPartialFailureStopsFurtherEdits(t *testing.T) { + mutations := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutations++ + require.Equal(t, "/api/v1/repos/owner/repo/pulls/1/requested_reviewers", r.URL.Path) + var input ReviewRequestInput + assert.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + if mutations == 1 { + require.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, ReviewRequestInput{Users: []string{"alice"}}, input) + w.WriteHeader(http.StatusNoContent) + } else { + require.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, ReviewRequestInput{Users: []string{"bob"}}, input) + w.WriteHeader(http.StatusForbidden) + } + })) + defer server.Close() + title := "New title" + changes := ReviewRequestChanges{Remove: ReviewRequestInput{Users: []string{"alice"}}, Add: ReviewRequestInput{Users: []string{"bob"}}} + _, err := (Pulls{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).Edit(context.Background(), 1, PullEdit{Title: &title, ReviewerChanges: &changes}) + require.ErrorContains(t, err, "reviewer removals succeeded") + require.Equal(t, 2, mutations) +} + +func TestUnknownReviewerTeamRejectsBeforeUserRemoval(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + switch r.URL.Path { + case "/api/v1/orgs/owner": + _, _ = w.Write([]byte(`{"id":1}`)) + case "/api/v1/orgs/owner/teams": + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + } + })) + defer server.Close() + changes := ReviewRequestChanges{Remove: ReviewRequestInput{Users: []string{"alice"}}, Add: ReviewRequestInput{Teams: []string{"missing"}}} + _, err := (Reviews{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).ChangeRequests(context.Background(), 1, changes) + require.ErrorContains(t, err, `team "missing" is unavailable`) +} diff --git a/internal/services/reviews.go b/internal/services/reviews.go new file mode 100644 index 00000000..0596e70f --- /dev/null +++ b/internal/services/reviews.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strings" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Review is a stable review result. +type Review struct { + ID int64 `json:"id"` + Body string `json:"body"` + State string `json:"state"` + Author string `json:"author"` + URL string `json:"url"` + Commit string `json:"commit"` + SubmittedAt string `json:"submitted_at"` + CommentsCount int `json:"comments_count"` + Dismissed bool `json:"dismissed"` +} + +func reviewFrom(review *gitea.PullReview) Review { + return Review{ + ID: review.ID, Body: review.Body, State: string(review.State), Author: login(review.Reviewer), URL: review.HTMLURL, + Commit: review.CommitID, SubmittedAt: timestamp(review.Submitted), CommentsCount: review.CodeCommentsCount, Dismissed: review.Dismissed, + } +} + +// ReviewComment describes an inline review conversation. +type ReviewComment struct { + ID int64 `json:"id"` + ReviewID int64 `json:"review_id"` + Body string `json:"body"` + Author string `json:"author"` + Resolver string `json:"resolver"` + URL string `json:"url"` + Path string `json:"path"` + Line int64 `json:"line"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func reviewCommentFrom(comment *gitea.PullReviewComment) ReviewComment { + return ReviewComment{ + ID: comment.ID, ReviewID: comment.ReviewID, Body: comment.Body, Author: login(comment.Reviewer), Resolver: login(comment.Resolver), + URL: comment.HTMLURL, Path: comment.Path, Line: int64(comment.LineNum), CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), + } +} + +// InlineComment supplies an explicit file position for review creation. +type InlineComment struct { + Path string `json:"path"` + Body string `json:"body"` + OldLine int64 `json:"old_position"` + NewLine int64 `json:"new_position"` +} + +// Validate requires a path, a body, and exactly one positive line position. +func (c InlineComment) Validate() error { + if strings.TrimSpace(c.Path) == "" || strings.TrimSpace(c.Body) == "" || c.OldLine < 0 || c.NewLine < 0 || (c.OldLine == 0) == (c.NewLine == 0) { + return errors.New("each inline comment requires path, body, and exactly one positive old_position or new_position") + } + return nil +} + +// ReviewOptions supplies collected review text and inline comments. +type ReviewOptions struct { + Event string `json:"event"` + Body string `json:"body"` + Commit string `json:"commit_id,omitempty"` + Comments []InlineComment `json:"comments,omitempty"` +} + +// ReviewSubmitOptions preserves existing draft text when Body is omitted. +type ReviewSubmitOptions struct { + Event string + Body *string +} + +// Reviews manages pull request reviews. +type Reviews struct { + Client *transport.Client + Repository string +} + +// List returns a non-nil page of reviews. +func (s Reviews) List(ctx context.Context, n int64, limit, page int) ([]Review, error) { + reviews, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.PullReview, *gitea.Response, error) { + return c.PullRequests.ListPullReviews(ctx, owner, name, n, gitea.ListPullReviewsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(reviews, reviewFrom), nil +} + +// View retrieves a review belonging to the selected pull request. +func (s Reviews) View(ctx context.Context, n, id int64) (Review, error) { + return repoConvert(ctx, s.Client, s.Repository, reviewFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReview, *gitea.Response, error) { + return c.PullRequests.GetPullReview(ctx, owner, name, n, id) + }) +} + +// Create submits a review. +func (s Reviews) Create(ctx context.Context, n int64, opts ReviewOptions) (Review, error) { + option := gitea.CreatePullReviewOptions{State: gitea.ReviewStateType(opts.Event), Body: opts.Body, CommitID: opts.Commit} + for _, comment := range opts.Comments { + option.Comments = append(option.Comments, gitea.CreatePullReviewComment{ + Path: comment.Path, Body: comment.Body, OldLineNum: comment.OldLine, NewLineNum: comment.NewLine, + }) + } + return repoConvert(ctx, s.Client, s.Repository, reviewFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReview, *gitea.Response, error) { + return c.PullRequests.CreatePullReview(ctx, owner, name, n, option) + }) +} + +// Submit publishes an existing pending review with explicitly collected input. +func (s Reviews) Submit(ctx context.Context, n, id int64, opts ReviewSubmitOptions) (Review, error) { + var body string + if opts.Body == nil { + current, err := s.View(ctx, n, id) + if err != nil { + return Review{}, err + } + body = current.Body + } else { + body = *opts.Body + } + option := gitea.SubmitPullReviewOptions{State: gitea.ReviewStateType(opts.Event), Body: body} + if option.State != gitea.ReviewStateApproved && strings.TrimSpace(body) == "" { + // TODO(sdk): SubmitPullReview requires a body unless the event is APPROVED, but Gitea accepts + // an empty body when the pending review has inline comments. + var review gitea.PullReview + endpoint := fmt.Sprintf("repos/%s/pulls/%d/reviews/%d", s.Repository, n, id) + if err := sendJSON(ctx, s.Client, http.MethodPost, endpoint, option, &review); err != nil { + return Review{}, err + } + return reviewFrom(&review), nil + } + return repoConvert(ctx, s.Client, s.Repository, reviewFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReview, *gitea.Response, error) { + return c.PullRequests.SubmitPullReview(ctx, owner, name, n, id, option) + }) +} + +// Delete removes one review. +func (s Reviews) Delete(ctx context.Context, n, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + return c.PullRequests.DeletePullReview(ctx, owner, name, n, id) + }) +} + +// Dismiss changes a review's contribution to approval requirements and returns the review. +func (s Reviews) Dismiss(ctx context.Context, n, id int64, dismiss bool, message string) (Review, error) { + err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + if dismiss { + return c.PullRequests.DismissPullReview(ctx, owner, name, n, id, gitea.DismissPullReviewOptions{Message: message}) + } + return c.PullRequests.UnDismissPullReview(ctx, owner, name, n, id) + }) + if err != nil { + return Review{}, err + } + review, err := s.View(ctx, n, id) + if err != nil { + return Review{}, fmt.Errorf("the review was updated, but reading it failed: %w", err) + } + return review, nil +} + +// Comments flattens the API's unpaginated per-review collections in ID order. +func (s Reviews) Comments(ctx context.Context, n, reviewID int64, limit, page int) ([]ReviewComment, error) { + reviews := []Review{{ID: reviewID}} + if reviewID == 0 { + var err error + reviews, err = listAll(func(page int) ([]Review, error) { return s.List(ctx, n, 100, page) }) + if err != nil { + return nil, err + } + } + results, err := s.reviewComments(ctx, n, reviews, reviewID == 0) + if err != nil { + return nil, err + } + return pageOf(results, limit, page) +} + +// Thread returns every review and every inline review comment of a pull request. +func (s Reviews) Thread(ctx context.Context, n int64) ([]Review, []ReviewComment, error) { + reviews, err := listAll(func(page int) ([]Review, error) { return s.List(ctx, n, 100, page) }) + if err != nil { + return nil, nil, err + } + comments, err := s.reviewComments(ctx, n, reviews, true) + return reviews, comments, err +} + +func (s Reviews) reviewComments(ctx context.Context, n int64, reviews []Review, skipEmpty bool) ([]ReviewComment, error) { + results := []ReviewComment{} + for _, review := range reviews { + if skipEmpty && review.CommentsCount == 0 { + continue + } + comments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.PullReviewComment, *gitea.Response, error) { + return c.PullRequests.ListPullReviewComments(ctx, owner, name, n, review.ID) + }) + if err != nil { + return nil, err + } + results = append(results, convertAll(comments, reviewCommentFrom)...) + } + sort.Slice(results, func(i, j int) bool { return results[i].ID < results[j].ID }) + return results, nil +} + +// Resolve marks a review conversation resolved or unresolved. +func (s Reviews) Resolve(ctx context.Context, id int64, resolved bool) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + if resolved { + return c.PullRequests.ResolvePullReviewComment(ctx, owner, name, id) + } + return c.PullRequests.UnresolvePullReviewComment(ctx, owner, name, id) + }) +} + +// Reply adds a reply to a review comment. +func (s Reviews) Reply(ctx context.Context, n, id int64, body string) (ReviewComment, error) { + return repoConvert(ctx, s.Client, s.Repository, reviewCommentFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReviewComment, *gitea.Response, error) { + return c.PullRequests.CreatePullReviewCommentReply(ctx, owner, name, n, id, gitea.CreatePullReviewCommentReplyOptions{Body: body}) + }) +} diff --git a/internal/services/reviews_test.go b/internal/services/reviews_test.go new file mode 100644 index 00000000..8086467f --- /dev/null +++ b/internal/services/reviews_test.go @@ -0,0 +1,205 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReviewCommentsContinueWhenServerCapsPageSize(t *testing.T) { + reviewPages, collections := 0, 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/owner/repo/pulls/3/reviews" { + reviewPages++ + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`[{"id":1,"comments_count":1}]`)) + case "2": + _, _ = w.Write([]byte(`[{"id":2,"comments_count":1}]`)) + case "3": + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected review page %q", r.URL.Query().Get("page")) + w.WriteHeader(http.StatusBadRequest) + } + return + } + collections++ + assert.Empty(t, r.URL.RawQuery, "per-review comments are unpaginated") + if r.URL.Path == "/api/v1/repos/owner/repo/pulls/3/reviews/1/comments" { + _, _ = w.Write([]byte(`[{"id":5,"position":4,"pull_request_review_id":1,"user":{"login":"alice"},"resolver":{"login":"bob"}}]`)) + } else { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/3/reviews/2/comments", r.URL.Path) + _, _ = w.Write([]byte(`[{"id":2}]`)) + } + })) + items, err := (Reviews{Client: client, Repository: "owner/repo"}).Comments(context.Background(), 3, 0, 1, 2) + require.NoError(t, err) + require.Len(t, items, 1) + require.EqualValues(t, 5, items[0].ID) + require.EqualValues(t, 4, items[0].Line) + require.Equal(t, "alice", items[0].Author) + require.Equal(t, "bob", items[0].Resolver) + require.Equal(t, 3, reviewPages) + require.Equal(t, 2, collections) +} + +func TestReviewDismissalReturnsTheUpdatedReview(t *testing.T) { + for _, dismiss := range []bool{false, true} { + t.Run(fmt.Sprintf("dismiss=%t", dismiss), func(t *testing.T) { + calls := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method == http.MethodGet { + assert.Equal(t, 2, calls, "the review is read after the mutation") + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews/2", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "dismissed": dismiss}) + return + } + assert.Equal(t, http.MethodPost, r.Method) + if dismiss { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews/2/dismissals", r.URL.Path) + var data map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&data)) + assert.Equal(t, map[string]string{"message": "Reason"}, data) + } else { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews/2/undismissals", r.URL.Path) + assert.Zero(t, r.ContentLength) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": 2, "dismissed": dismiss}) + })) + item, err := (Reviews{Client: client, Repository: "owner/repo"}).Dismiss(context.Background(), 1, 2, dismiss, "Reason") + require.NoError(t, err) + require.EqualValues(t, 2, item.ID) + require.Equal(t, dismiss, item.Dismissed) + require.Equal(t, 2, calls) + }) + } +} + +func TestReviewSubmitPreservesBodyUnlessReplaced(t *testing.T) { + empty := "" + for name, test := range map[string]struct { + body *string + wantBody string + wantGets int + }{ + "omitted keeps current": {wantBody: "Pending notes", wantGets: 1}, + "explicit empty clears": {body: &empty, wantBody: ""}, + } { + t.Run(name, func(t *testing.T) { + gets := 0 + var submitted map[string]string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews/2", r.URL.Path) + if r.Method == http.MethodGet { + gets++ + _, _ = w.Write([]byte(`{"id":2,"body":"Pending notes","state":"PENDING"}`)) + return + } + assert.Equal(t, http.MethodPost, r.Method) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&submitted)) + _, _ = w.Write([]byte(`{"id":2,"state":"APPROVED"}`)) + })) + item, err := (Reviews{Client: client, Repository: "owner/repo"}).Submit(context.Background(), 1, 2, ReviewSubmitOptions{Event: "APPROVED", Body: test.body}) + require.NoError(t, err) + require.EqualValues(t, 2, item.ID) + require.Equal(t, test.wantGets, gets) + require.Equal(t, map[string]string{"event": "APPROVED", "body": test.wantBody}, submitted) + }) + } +} + +func TestReviewSubmitAllowsEmptyCommentBody(t *testing.T) { + var submitted map[string]string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST /api/v1/repos/owner/repo/pulls/1/reviews/2", r.Method+" "+r.URL.Path) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&submitted)) + _, _ = w.Write([]byte(`{"id":2,"state":"COMMENT","comments_count":1}`)) + })) + empty := "" + item, err := (Reviews{Client: client, Repository: "owner/repo"}).Submit(context.Background(), 1, 2, ReviewSubmitOptions{Event: "COMMENT", Body: &empty}) + require.NoError(t, err) + require.Equal(t, Review{ID: 2, State: "COMMENT", CommentsCount: 1}, item) + require.Equal(t, map[string]string{"event": "COMMENT", "body": ""}, submitted) +} + +func TestReviewCreateSendsInlineComments(t *testing.T) { + var created struct { + Event string `json:"event"` + Body string `json:"body"` + Commit string `json:"commit_id"` + Comments []InlineComment + } + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/owner/repo/pulls/1/reviews", r.URL.Path) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&created)) + _, _ = w.Write([]byte(`{"id":7,"state":"REQUEST_CHANGES","user":{"login":"alice"},"commit_id":"abc"}`)) + })) + item, err := (Reviews{Client: client, Repository: "owner/repo"}).Create(context.Background(), 1, ReviewOptions{ + Event: "REQUEST_CHANGES", Body: "Fix", Commit: "abc", + Comments: []InlineComment{{Path: "a.go", Body: "here", NewLine: 3}, {Path: "b.go", Body: "there", OldLine: 2}}, + }) + require.NoError(t, err) + require.Equal(t, Review{ID: 7, State: "REQUEST_CHANGES", Author: "alice", Commit: "abc"}, item) + require.Equal(t, "REQUEST_CHANGES", created.Event) + require.Equal(t, "Fix", created.Body) + require.Equal(t, "abc", created.Commit) + require.Equal(t, []InlineComment{{Path: "a.go", Body: "here", NewLine: 3}, {Path: "b.go", Body: "there", OldLine: 2}}, created.Comments) +} + +func TestReviewResolveAndReplyUseCommentEndpoints(t *testing.T) { + paths := []string{} + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + paths = append(paths, r.URL.Path) + if r.URL.Path == "/api/v1/repos/owner/repo/pulls/1/comments/5/replies" { + var reply map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reply)) + assert.Equal(t, map[string]string{"body": "Done"}, reply) + _, _ = w.Write([]byte(`{"id":6,"body":"Done","user":{"login":"bob"},"pull_request_review_id":3}`)) + return + } + w.WriteHeader(http.StatusOK) + })) + service := Reviews{Client: client, Repository: "owner/repo"} + require.NoError(t, service.Resolve(context.Background(), 5, true)) + require.NoError(t, service.Resolve(context.Background(), 5, false)) + reply, err := service.Reply(context.Background(), 1, 5, "Done") + require.NoError(t, err) + require.Equal(t, ReviewComment{ID: 6, ReviewID: 3, Body: "Done", Author: "bob"}, reply) + require.Equal(t, []string{ + "/api/v1/repos/owner/repo/pulls/comments/5/resolve", + "/api/v1/repos/owner/repo/pulls/comments/5/unresolve", + "/api/v1/repos/owner/repo/pulls/1/comments/5/replies", + }, paths) +} + +func TestReviewFromMapsServerFields(t *testing.T) { + review := decode[gitea.PullReview](t, `{"id":2,"body":"LGTM","state":"APPROVED","user":{"login":"alice"},"html_url":"https://gitea.test/r/2", + "commit_id":"abc","submitted_at":"2026-10-01T12:00:00Z","comments_count":3,"dismissed":true}`) + require.Equal(t, Review{ + ID: 2, Body: "LGTM", State: "APPROVED", Author: "alice", URL: "https://gitea.test/r/2", Commit: "abc", + SubmittedAt: "2026-10-01T12:00:00Z", CommentsCount: 3, Dismissed: true, + }, reviewFrom(review)) +} + +func TestReviewCommentFromMapsServerFields(t *testing.T) { + comment := decode[gitea.PullReviewComment](t, `{"id":5,"pull_request_review_id":1,"body":"Nit","user":{"login":"alice"}, + "resolver":{"login":"bob"},"html_url":"https://gitea.test/c/5","path":"main.go","position":12, + "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00Z"}`) + require.Equal(t, ReviewComment{ + ID: 5, ReviewID: 1, Body: "Nit", Author: "alice", Resolver: "bob", URL: "https://gitea.test/c/5", Path: "main.go", Line: 12, + CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00Z", + }, reviewCommentFrom(comment)) +} diff --git a/internal/services/sdk.go b/internal/services/sdk.go new file mode 100644 index 00000000..04c23ff3 --- /dev/null +++ b/internal/services/sdk.go @@ -0,0 +1,166 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// sdkClient binds the SDK to the instance's API transport, which owns authentication, retries, +// redirects, and error capture. The empty version disables the SDK's version probe and gates. +func sdkClient(client *transport.Client) (*gitea.Client, error) { + return gitea.NewClient(client.BaseURL, gitea.SetHTTPClient(client.API()), gitea.SetGiteaVersion("")) +} + +// call runs one SDK operation and maps a failure onto the errors raw requests report: the +// context error, a sanitized *transport.APIError, or a network error without the request URL. +func call[T any](ctx context.Context, client *transport.Client, op func(context.Context, *gitea.Client) (T, *gitea.Response, error)) (T, *gitea.Response, error) { + var zero T + sdk, err := sdkClient(client) + if err != nil { + return zero, nil, err + } + ctx, captured := transport.CaptureErrors(ctx) + result, resp, err := op(ctx, sdk) + if err = sdkError(ctx, resp, err, captured); err != nil { + return zero, resp, err + } + return result, resp, nil +} + +// do is call for SDK operations that return no value. +func do(ctx context.Context, client *transport.Client, op func(context.Context, *gitea.Client) (*gitea.Response, error)) error { + _, _, err := call(ctx, client, func(ctx context.Context, sdk *gitea.Client) (struct{}, *gitea.Response, error) { + resp, err := op(ctx, sdk) + return struct{}{}, resp, err + }) + return err +} + +func sdkError(ctx context.Context, resp *gitea.Response, err error, captured func() *transport.APIError) error { + failed := resp != nil && resp.Response != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) + if err == nil && !failed { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if apiErr := captured(); apiErr != nil { + return apiErr + } + if failed { + return &transport.APIError{Status: resp.StatusCode, Method: resp.Request.Method, Endpoint: resp.Request.URL.Path} + } + var urlErr *url.Error + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + switch { + case errors.As(err, &urlErr): + return transport.RequestError(ctx, "API request failed", err) + case errors.As(err, &syntaxErr), errors.As(err, &typeErr): + return errInvalidJSON + } + return err +} + +// repoOperation is an SDK operation on one repository. +type repoOperation[T any] func(ctx context.Context, c *gitea.Client, owner, repo string) (T, *gitea.Response, error) + +// repoCall is call for an operation on an owner/name repository. +func repoCall[T any](ctx context.Context, client *transport.Client, repository string, op repoOperation[T]) (T, error) { + owner, repo, err := splitRepository(repository) + if err != nil { + var zero T + return zero, err + } + result, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (T, *gitea.Response, error) { + return op(ctx, c, owner, repo) + }) + return result, err +} + +// repoDo is repoCall for operations that return no value. +func repoDo(ctx context.Context, client *transport.Client, repository string, op func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error)) error { + _, err := repoCall(ctx, client, repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (struct{}, *gitea.Response, error) { + resp, err := op(ctx, c, owner, repo) + return struct{}{}, resp, err + }) + return err +} + +// repoConvert is repoCall for an operation whose result maps onto a tea value. +func repoConvert[S, T any](ctx context.Context, client *transport.Client, repository string, convert func(*S) T, op repoOperation[*S]) (T, error) { + item, err := repoCall(ctx, client, repository, op) + if err != nil { + var zero T + return zero, err + } + return convert(item), nil +} + +// splitRepository separates an owner/name repository reference for SDK calls. +func splitRepository(repository string) (owner, name string, err error) { + owner, name, ok := strings.Cut(repository, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return "", "", fmt.Errorf("invalid repository %q; use owner/name", repository) + } + return owner, name, nil +} + +// listOptions always sets the page size, so the server never applies its own default. +func listOptions(limit, page int) gitea.ListOptions { + return gitea.ListOptions{Page: page, PageSize: limit} +} + +// headerOf returns the response headers that carry pagination hints, or none without a response. +func headerOf(resp *gitea.Response) http.Header { + if resp == nil || resp.Response == nil { + return http.Header{} + } + return resp.Header +} + +// deref returns the value behind an optional field, or its zero value when the field is absent. +func deref[T any](value *T) T { + if value == nil { + var zero T + return zero + } + return *value +} + +// timestamp renders an SDK time as tea's RFC 3339 string, or "" when the server sent none. +func timestamp(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339Nano) +} + +// optionalTimestamp is timestamp for nullable fields, returning nil when the time is absent. +func optionalTimestamp(t *time.Time) *string { + if t == nil || t.IsZero() { + return nil + } + value := timestamp(*t) + return &value +} + +// login returns the user's login, or "" for an absent user. +func login(user *gitea.User) string { + if user == nil { + return "" + } + return user.UserName +} diff --git a/internal/services/sdk_test.go b/internal/services/sdk_test.go new file mode 100644 index 00000000..69c375ea --- /dev/null +++ b/internal/services/sdk_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestSDKCallsAuthenticateWithoutVersionProbe(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/alice/project/branches/main", r.URL.Path) + require.Equal(t, "token secret", r.Header.Get("Authorization")) + require.Equal(t, "bob", r.Header.Get("Sudo")) + require.Equal(t, transport.UserAgent, r.Header.Get("User-Agent")) + w.WriteHeader(http.StatusNoContent) + })) + client.Token, client.Sudo = "secret", "bob" + renamed, _, err := call(context.Background(), client, func(ctx context.Context, c *gitea.Client) (bool, *gitea.Response, error) { + return c.Repositories.RenameRepoBranch(ctx, "alice", "project", "main", gitea.RenameRepoBranchOption{Name: "trunk"}) + }) + require.NoError(t, err) + require.True(t, renamed) +} + +func TestSDKErrorsMapToAPIErrors(t *testing.T) { + for _, tc := range []struct { + name, body, message string + status int + }{ + {"not found", `{"message":"repository does not exist"}`, "repository does not exist", http.StatusNotFound}, + {"method refused", `{"message":"Please try again later"}`, "Please try again later", http.StatusMethodNotAllowed}, + {"no message", `oops`, "", http.StatusInternalServerError}, + } { + t.Run(tc.name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + client.Token = "secret" + err := do(context.Background(), client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Repositories.DeleteRepo(ctx, "alice", "project") + }) + require.Equal(t, &transport.APIError{Status: tc.status, Method: http.MethodDelete, Endpoint: "/api/v1/repos/alice/project", Message: tc.message}, err) + require.NotContains(t, err.Error(), "secret") + }) + } +} + +func TestSDKStatusOnlyFailureKeepsServerMessage(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"branch trunk already exists"}`)) + })) + renamed, _, err := call(context.Background(), client, func(ctx context.Context, c *gitea.Client) (bool, *gitea.Response, error) { + return c.Repositories.RenameRepoBranch(ctx, "alice", "project", "main", gitea.RenameRepoBranchOption{Name: "trunk"}) + }) + require.False(t, renamed) + require.Equal(t, &transport.APIError{Status: http.StatusUnprocessableEntity, Method: http.MethodPatch, Endpoint: "/api/v1/repos/alice/project/branches/main", Message: "branch trunk already exists"}, err) + require.EqualError(t, err, "Gitea API returned HTTP 422 for PATCH /api/v1/repos/alice/project/branches/main: branch trunk already exists") +} + +func TestSDKFailureWithoutResponseKeepsCapturedError(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"repository does not exist"}`)) + })) + _, _, err := call(context.Background(), client, func(ctx context.Context, c *gitea.Client) (*gitea.Repository, *gitea.Response, error) { + _, _, err := c.Repositories.GetRepo(ctx, "alice", "project") + return nil, nil, err + }) + require.Equal(t, &transport.APIError{Status: http.StatusNotFound, Method: http.MethodGet, Endpoint: "/api/v1/repos/alice/project", Message: "repository does not exist"}, err) +} + +func TestSDKInvalidJSONIsReportedWithoutBody(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"login":`)) + })) + _, _, err := call(context.Background(), client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetMyUserInfo(ctx) + }) + require.ErrorIs(t, err, errInvalidJSON) + client = newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"login":42}`)) + })) + _, _, err = call(context.Background(), client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetMyUserInfo(ctx) + }) + require.ErrorIs(t, err, errInvalidJSON) +} + +func TestSDKCancellationReturnsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cancel() + <-r.Context().Done() + })) + _, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetMyUserInfo(ctx) + }) + require.ErrorIs(t, err, context.Canceled) +} + +func TestSDKNetworkErrorsOmitRequestURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + server.Close() + _, _, err := call(context.Background(), transport.New(server.URL, "", false), func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) { + return c.Users.GetUserInfo(ctx, "query-secret") + }) + var opErr *net.OpError + require.ErrorAs(t, err, &opErr) + require.ErrorContains(t, err, "API request failed: ") + require.NotContains(t, err.Error(), "query-secret") +} + +func TestSDKValidationErrorsPassThrough(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("invalid input must not reach the server") + })) + err := do(context.Background(), client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Repositories.DeleteRepo(ctx, "", "project") + }) + require.Error(t, err) + var apiErr *transport.APIError + require.NotErrorAs(t, err, &apiErr) +} + +func TestSplitRepository(t *testing.T) { + owner, name, err := splitRepository("alice/project") + require.NoError(t, err) + require.Equal(t, []string{"alice", "project"}, []string{owner, name}) + for _, invalid := range []string{"", "alice", "/project", "alice/", "alice/project/extra"} { + _, _, err := splitRepository(invalid) + require.Error(t, err, invalid) + } +} + +func TestServiceFailuresReachTheCaller(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + ctx := context.Background() + for name, run := range map[string]func() error{ + "branch delete": func() error { return (Branches{Client: client}).Delete(ctx, "alice/project", "main") }, + "repository view": func() error { _, err := (Repositories{Client: client}).View(ctx, "alice/project"); return err }, + "ssh key view": func() error { _, err := (SSHKeys{Client: client}).View(ctx, 9); return err }, + } { + var apiErr *transport.APIError + require.ErrorAs(t, run(), &apiErr, name) + require.Equal(t, "not found", apiErr.Message, name) + } + for name, run := range map[string]func() error{ + "repository delete": func() error { return (Repositories{Client: client}).Delete(ctx, "alice") }, + "workflows": func() error { _, err := (Actions{Client: client, Repository: "alice"}).Workflows(ctx); return err }, + } { + require.ErrorContains(t, run(), "use owner/name", name) + } +} + +func TestSDKResponseHelpers(t *testing.T) { + require.Equal(t, gitea.ListOptions{Page: 3, PageSize: 20}, listOptions(20, 3)) + require.Empty(t, headerOf(nil)) + require.Empty(t, headerOf(&gitea.Response{})) + require.Equal(t, "2", headerOf(&gitea.Response{Response: &http.Response{Header: http.Header{"X-Total-Count": {"2"}}}}).Get("X-Total-Count")) + require.Empty(t, timestamp(time.Time{})) + require.Nil(t, optionalTimestamp(nil)) + require.Nil(t, optionalTimestamp(&time.Time{})) + moment := time.Date(2026, 10, 1, 12, 0, 0, 0, time.FixedZone("", 2*60*60)) + require.Equal(t, "2026-10-01T12:00:00+02:00", timestamp(moment)) + require.Equal(t, "2026-10-01T12:00:00+02:00", *optionalTimestamp(&moment)) + require.Empty(t, login(nil)) + require.Empty(t, deref[string](nil)) + name := "alice" + require.Equal(t, "alice", deref(&name)) +} diff --git a/internal/services/search.go b/internal/services/search.go new file mode 100644 index 00000000..60901c17 --- /dev/null +++ b/internal/services/search.go @@ -0,0 +1,132 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + gitea "gitea.dev/sdk" +) + +// SearchItem is shared issue/PR search metadata; ID is Gitea's issue-tracker +// record ID, including for PRs. Repository and Number identify the resource +// across search and pull endpoints, which use different internal IDs. +type SearchItem struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + Type string `json:"type"` + Repository string `json:"repository"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// DiscussionSearchOptions specifies issue or pull request filters. +type DiscussionSearchOptions struct { + Query, State, Owner, Team, Author, Labels, Milestones string + Since, Before *time.Time + Pulls, Assigned, Authored, Mentioned, ReviewRequested, Reviewed bool + Limit, Page int +} + +// Search searches one repository or accessible repositories across the selected instance. +func (s Issues) Search(ctx context.Context, opts DiscussionSearchOptions) ([]SearchItem, error) { + kind, resultType := gitea.IssueTypeIssue, "issue" + if opts.Pulls { + kind, resultType = gitea.IssueTypePull, "pr" + } + if opts.Team != "" && opts.Owner == "" { + return nil, fmt.Errorf("team search requires an organization owner") + } + search := s.searchInstance + if s.Repository != "" { + search = s.searchRepository + } + issues, err := search(ctx, opts, kind) + if err != nil { + return nil, err + } + return convertAll(issues, func(issue *gitea.Issue) SearchItem { + repository := s.Repository + if issue.Repository != nil { + repository = issue.Repository.FullName + } + return SearchItem{ + ID: issue.ID, Number: issue.Index, Title: issue.Title, Body: issue.Body, URL: issue.HTMLURL, State: string(issue.State), + Type: resultType, Repository: repository, CreatedAt: timestamp(issue.Created), UpdatedAt: timestamp(issue.Updated), + } + }), nil +} + +func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) ([]*gitea.Issue, error) { + if opts.ReviewRequested || opts.Reviewed { + return nil, &CapabilityError{Feature: "repository-scoped review search", Reason: "Gitea supports review filters on instance-wide search only; omit --repo or TEA_REPO"} + } + if opts.Team != "" { + return nil, &CapabilityError{Feature: "repository-scoped team search", Reason: "Gitea supports team filters on instance-wide search only; omit --repo or TEA_REPO"} + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, err + } + query := gitea.ListIssueOption{ + ListOptions: listOptions(opts.Limit, opts.Page), State: gitea.StateType(opts.State), Type: kind, KeyWord: opts.Query, + Labels: filterValues(opts.Labels), Milestones: filterValues(opts.Milestones), CreatedBy: opts.Author, Owner: opts.Owner, + } + if opts.Since != nil { + query.Since = *opts.Since + } + if opts.Before != nil { + query.Before = *opts.Before + } + if opts.Authored || opts.Assigned || opts.Mentioned { + account, err := CurrentAccount(ctx, s.Client) + if err != nil { + return nil, err + } + if opts.Authored { + query.CreatedBy = account + } + if opts.Assigned { + query.AssignedBy = account + } + if opts.Mentioned { + query.MentionedBy = account + } + } + issues, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { + return c.Issues.ListRepoIssues(ctx, owner, repo, query) + }) + return issues, err +} + +func (s Issues) searchInstance(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) ([]*gitea.Issue, error) { + query := url.Values{"q": {opts.Query}, "state": {opts.State}, "type": {string(kind)}, "limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}} + for key, value := range map[string]string{"owner": opts.Owner, "team": opts.Team, "created_by": opts.Author, "labels": opts.Labels, "milestones": opts.Milestones} { + if value != "" { + query.Set(key, value) + } + } + for key, value := range map[string]*time.Time{"since": opts.Since, "before": opts.Before} { + if value != nil { + query.Set(key, value.Format(time.RFC3339)) + } + } + for key, enabled := range map[string]bool{"created": opts.Authored, "assigned": opts.Assigned, "mentioned": opts.Mentioned, "review_requested": opts.ReviewRequested, "reviewed": opts.Reviewed} { + if enabled { + query.Set(key, "true") + } + } + var issues []*gitea.Issue + // TODO(sdk): ListIssueOption lacks the assigned, created, mentioned, review_requested and reviewed flags. + err := getJSON(ctx, s.Client, "repos/issues/search?"+query.Encode(), &issues) + return issues, err +} diff --git a/internal/services/search_test.go b/internal/services/search_test.go new file mode 100644 index 00000000..6d69355d --- /dev/null +++ b/internal/services/search_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscussionSearchTeamRequiresOwnerAndInstanceScope(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/issues/search", r.URL.Path) + require.Equal(t, "team-org", r.URL.Query().Get("owner")) + require.Equal(t, "devs", r.URL.Query().Get("team")) + require.Equal(t, "pulls", r.URL.Query().Get("type")) + _, _ = w.Write([]byte(`[{"id":3,"number":4,"title":"Team PR","html_url":"https://instance/team-org/repo/pulls/4","repository":{"full_name":"team-org/repo"}}]`)) + })) + service := Issues{Client: client} + _, err := service.Search(context.Background(), DiscussionSearchOptions{Team: "devs", Pulls: true, State: "open", Limit: 30, Page: 1}) + require.ErrorContains(t, err, "organization owner") + items, err := service.Search(context.Background(), DiscussionSearchOptions{Owner: "team-org", Team: "devs", Pulls: true, State: "open", Limit: 30, Page: 1}) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, "pr", items[0].Type) + repoService := Issues{Client: client, Repository: "team-org/repo"} + _, err = repoService.Search(context.Background(), DiscussionSearchOptions{Owner: "team-org", Team: "devs", State: "open", Limit: 30, Page: 1}) + require.ErrorContains(t, err, "instance-wide") +} + +func TestDiscussionSearchScopeAndSelfFilters(t *testing.T) { + for _, repository := range []string{"", "owner/repo"} { + t.Run(repository, func(t *testing.T) { + requests := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path == "/api/v1/user" { + _, _ = w.Write([]byte(`{"login":"account"}`)) + return + } + require.Equal(t, "issues", r.URL.Query().Get("type")) + require.Equal(t, "open", r.URL.Query().Get("state")) + if repository == "" { + require.Equal(t, "/api/v1/repos/issues/search", r.URL.Path) + require.Equal(t, "true", r.URL.Query().Get("assigned")) + require.Equal(t, "true", r.URL.Query().Get("created")) + } else { + require.Equal(t, "/api/v1/repos/owner/repo/issues", r.URL.Path) + require.Equal(t, "account", r.URL.Query().Get("assigned_by")) + require.Equal(t, "account", r.URL.Query().Get("created_by")) + } + _, _ = w.Write([]byte(`[{"id":1,"number":2,"title":"Result","html_url":"https://instance/owner/repo/issues/2","repository":{"full_name":"owner/repo"}}]`)) + })) + service := Issues{Client: client, Repository: repository} + items, err := service.Search(context.Background(), DiscussionSearchOptions{State: "open", Assigned: true, Authored: true, Limit: 30, Page: 1}) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, "issue", items[0].Type) + require.Equal(t, "owner/repo", items[0].Repository) + if repository != "" { + previous := requests + _, err := service.Search(context.Background(), DiscussionSearchOptions{Pulls: true, ReviewRequested: true}) + require.ErrorContains(t, err, "instance-wide") + require.Equal(t, previous, requests) + } + }) + } +} diff --git a/internal/services/ssh_keys.go b/internal/services/ssh_keys.go new file mode 100644 index 00000000..9b8a627a --- /dev/null +++ b/internal/services/ssh_keys.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// SSHKey describes a public key attached to the selected account. +type SSHKey struct { + ID int64 `json:"id"` + Title string `json:"title"` + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` + CreatedAt string `json:"created_at"` + ReadOnly bool `json:"read_only"` +} + +func sshKeyFrom(key *gitea.PublicKey) SSHKey { + return SSHKey{ID: key.ID, Title: key.Title, Key: key.Key, Fingerprint: key.Fingerprint, CreatedAt: timestamp(key.Created), ReadOnly: key.ReadOnly} +} + +// SSHKeys manages public keys for the authenticated account. +type SSHKeys struct{ Client *transport.Client } + +// List returns a non-nil page of public keys. +func (s SSHKeys) List(ctx context.Context, limit, page int) ([]SSHKey, error) { + keys, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.PublicKey, *gitea.Response, error) { + return c.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(keys, sshKeyFrom), nil +} + +// View retrieves a public key by ID. +func (s SSHKeys) View(ctx context.Context, id int64) (SSHKey, error) { + key, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.PublicKey, *gitea.Response, error) { + return c.Users.GetPublicKey(ctx, id) + }) + if err != nil { + return SSHKey{}, err + } + return sshKeyFrom(key), nil +} + +// Create registers one public key for the account. +func (s SSHKeys) Create(ctx context.Context, title, key string) (SSHKey, error) { + created, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.PublicKey, *gitea.Response, error) { + return c.Users.CreatePublicKey(ctx, gitea.CreateKeyOption{Title: title, Key: key}) + }) + if err != nil { + return SSHKey{}, err + } + return sshKeyFrom(created), nil +} + +// Delete removes only the selected public key. +func (s SSHKeys) Delete(ctx context.Context, id int64) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Users.DeletePublicKey(ctx, id) + }) +} diff --git a/internal/services/ssh_keys_test.go b/internal/services/ssh_keys_test.go new file mode 100644 index 00000000..2e6ab6c2 --- /dev/null +++ b/internal/services/ssh_keys_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sshKeyJSON = `{"id":3,"title":"laptop","key":"ssh-ed25519 AAAA","fingerprint":"SHA256:abc","created_at":"2026-10-01T12:00:00Z","read_only":true,"user":{"login":"alice"}}` + +func TestSSHKeyListPaginatesAndMapsKeys(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/user/keys", r.URL.Path) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + if r.URL.Query().Get("page") == "2" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[` + sshKeyJSON + `]`)) + })) + service := SSHKeys{Client: client} + keys, err := service.List(context.Background(), 20, 1) + require.NoError(t, err) + require.Equal(t, []SSHKey{{ID: 3, Title: "laptop", Key: "ssh-ed25519 AAAA", Fingerprint: "SHA256:abc", CreatedAt: "2026-10-01T12:00:00Z", ReadOnly: true}}, keys) + keys, err = service.List(context.Background(), 20, 2) + require.NoError(t, err) + require.NotNil(t, keys) + require.Empty(t, keys) +} + +func TestSSHKeyViewCreateAndDelete(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + switch r.Method { + case http.MethodPost: + payload := requestJSON(t, r) + assert.Equal(t, map[string]any{"title": "laptop", "key": "ssh-ed25519 AAAA", "read_only": false}, payload) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(sshKeyJSON)) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + _, _ = w.Write([]byte(sshKeyJSON)) + } + })) + service := SSHKeys{Client: client} + key, err := service.View(context.Background(), 3) + require.NoError(t, err) + require.Equal(t, "laptop", key.Title) + created, err := service.Create(context.Background(), "laptop", "ssh-ed25519 AAAA") + require.NoError(t, err) + require.EqualValues(t, 3, created.ID) + require.NoError(t, service.Delete(context.Background(), 3)) + require.Equal(t, []string{"GET /api/v1/user/keys/3", "POST /api/v1/user/keys", "DELETE /api/v1/user/keys/3"}, requests) +} diff --git a/internal/services/status.go b/internal/services/status.go new file mode 100644 index 00000000..5a836cd7 --- /dev/null +++ b/internal/services/status.go @@ -0,0 +1,120 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "strings" + + "gitea.dev/tea/internal/transport" +) + +// PullStatus groups open pull requests by the selected account's participation. +type PullStatus struct { + Authored []SearchItem `json:"authored"` + Assigned []SearchItem `json:"assigned"` + ReviewRequested []SearchItem `json:"review_requested"` +} + +// AccountStatus is an account summary with optional repository Actions results. +type AccountStatus struct { + Host string `json:"host"` + User string `json:"user"` + Repository string `json:"repository"` + AssignedIssues []SearchItem `json:"assigned_issues"` + PullRequests PullStatus `json:"pull_requests"` + FailingRuns []Run `json:"failing_runs"` + RunsIncluded bool `json:"runs_included"` +} + +// Status reuses search and Actions workflows. +type Status struct { + Client *transport.Client + Repository string +} + +// Pulls collects each category independently; limit applies after repository filtering. +func (s Status) Pulls(ctx context.Context, limit int) (PullStatus, error) { + result := PullStatus{Authored: []SearchItem{}, Assigned: []SearchItem{}, ReviewRequested: []SearchItem{}} + if limit < 1 || limit > 100 { + return result, fmt.Errorf("status limit must be 1..100") + } + scoped := Issues{Client: s.Client, Repository: s.Repository} + opts := DiscussionSearchOptions{Pulls: true, State: "open", Limit: limit, Page: 1, Authored: true} + var err error + result.Authored, err = scoped.Search(ctx, opts) + if err != nil { + return result, err + } + opts.Authored, opts.Assigned = false, true + result.Assigned, err = scoped.Search(ctx, opts) + if err != nil { + return result, err + } + opts.Assigned, opts.ReviewRequested = false, true + if s.Repository == "" { + result.ReviewRequested, err = scoped.Search(ctx, opts) + return result, err + } + // Gitea exposes review filters only on instance-wide search. Filter after + // pagination so requests from other repositories do not consume the limit. + opts.Owner = strings.SplitN(s.Repository, "/", 2)[0] + opts.Limit = 100 + for opts.Page = 1; opts.Page <= maxListPages; opts.Page++ { + items, err := (Issues{Client: s.Client}).Search(ctx, opts) + if err != nil { + return result, err + } + if len(items) == 0 { + return result, nil + } + for _, item := range items { + if strings.EqualFold(item.Repository, s.Repository) { + result.ReviewRequested = append(result.ReviewRequested, item) + if len(result.ReviewRequested) == limit { + return result, nil + } + } + } + } + return result, fmt.Errorf("review requests exceeded %d pages", maxListPages) +} + +// Summary reports open assigned issues and pull requests. Actions results are +// included only when requested and a repository has been selected. +func (s Status) Summary(ctx context.Context, limit int, runs bool) (AccountStatus, error) { + result := AccountStatus{Host: s.Client.BaseURL, Repository: s.Repository, AssignedIssues: []SearchItem{}, FailingRuns: []Run{}} + if limit < 1 || limit > 100 { + return result, fmt.Errorf("status limit must be 1..100") + } + account, err := CurrentAccount(ctx, s.Client) + if err != nil { + return result, err + } + result.User = account + result.PullRequests, err = s.Pulls(ctx, limit) + if err != nil { + return result, err + } + result.AssignedIssues, err = (Issues{Client: s.Client, Repository: s.Repository}).Search(ctx, DiscussionSearchOptions{State: "open", Assigned: true, Limit: limit, Page: 1}) + if err != nil { + return result, err + } + if runs && s.Repository != "" { + repository, err := (Repositories{Client: s.Client}).View(ctx, s.Repository) + if err != nil { + return result, err + } + if !repository.ActionsEnabled { + return result, nil + } + failing, err := (Actions{Client: s.Client, Repository: s.Repository}).Runs(ctx, RunListOptions{Limit: limit, Page: 1, Statuses: []string{"failure", "timed_out"}}) + if err != nil { + return result, err + } + result.FailingRuns, result.RunsIncluded = failing, true + } + return result, nil +} diff --git a/internal/services/status_test.go b/internal/services/status_test.go new file mode 100644 index 00000000..4f9b40f9 --- /dev/null +++ b/internal/services/status_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestPullStatusFiltersRequestedReviewsBeforeLimiting(t *testing.T) { + pages := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/user": + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case "/api/v1/repos/alice/project/issues": + require.Equal(t, "pulls", r.URL.Query().Get("type")) + require.Equal(t, "open", r.URL.Query().Get("state")) + require.Equal(t, "1", r.URL.Query().Get("limit")) + require.True(t, r.URL.Query().Get("created_by") == "alice" || r.URL.Query().Get("assigned_by") == "alice") + _, _ = w.Write([]byte(`[]`)) + case "/api/v1/repos/issues/search": + require.Equal(t, "true", r.URL.Query().Get("review_requested")) + require.Equal(t, "alice", r.URL.Query().Get("owner")) + require.Equal(t, "100", r.URL.Query().Get("limit")) + page := r.URL.Query().Get("page") + pages = append(pages, page) + switch page { + case "1": + _, _ = w.Write([]byte(`[{"id":1,"number":1,"repository":{"full_name":"alice/other"}}]`)) + case "2": + _, _ = w.Write([]byte(`[{"id":2,"number":2,"repository":{"full_name":"Alice/Project"}},{"id":3,"number":3,"repository":{"full_name":"alice/project"}}]`)) + default: + t.Fatal("status fetched beyond the requested limit") + } + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + result, err := (Status{Client: transport.New(server.URL, "token", false), Repository: "alice/project"}).Pulls(context.Background(), 1) + require.NoError(t, err) + require.Empty(t, result.Authored) + require.Empty(t, result.Assigned) + require.Len(t, result.ReviewRequested, 1) + require.Equal(t, int64(2), result.ReviewRequested[0].ID) + require.Equal(t, []string{"1", "2"}, pages) +} + +func TestStatusSummarySkipsRunsWhenActionsAreDisabled(t *testing.T) { + accounts := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/user": + accounts++ + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case r.URL.Path == "/api/v1/repos/alice/project": + _, _ = w.Write([]byte(`{"has_actions":false}`)) + case r.URL.Path == "/api/v1/repos/alice/project/issues": + _, _ = w.Write([]byte(`[{"id":4,"number":4}]`)) + case r.URL.Path == "/api/v1/repos/issues/search": + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + result, err := (Status{Client: client, Repository: "alice/project"}).Summary(context.Background(), 30, true) + require.NoError(t, err) + require.False(t, result.RunsIncluded) + require.Empty(t, result.FailingRuns) + require.Len(t, result.AssignedIssues, 1) + require.Equal(t, 1, accounts) +} diff --git a/internal/services/teams.go b/internal/services/teams.go new file mode 100644 index 00000000..84d0bbe5 --- /dev/null +++ b/internal/services/teams.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "fmt" + "net/http" + "strings" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Team is an organization's access group and its permission policy. +type Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Organization string `json:"organization"` + Permission string `json:"permission"` + CanCreateRepo bool `json:"can_create_repo"` + AllRepositories bool `json:"all_repositories"` + Units map[string]string `json:"units"` +} + +func teamFrom(team *gitea.Team) Team { + result := Team{ + ID: team.ID, Name: team.Name, Description: team.Description, Permission: string(team.Permission), + CanCreateRepo: team.CanCreateOrgRepo, AllRepositories: team.IncludesAllRepositories, Units: team.UnitsMap, + } + if result.Units == nil { + result.Units = map[string]string{} + } + if team.Organization != nil { + result.Organization = organizationFrom(team.Organization).Name + } + return result +} + +// TeamOptions preserves omitted fields when changing a team. +type TeamOptions struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Permission *string `json:"permission,omitempty"` + CanCreateRepo *bool `json:"can_create_org_repo,omitempty"` + AllRepositories *bool `json:"includes_all_repositories,omitempty"` + Units map[string]string `json:"units_map,omitempty"` +} + +// Teams implements team, member and repository access operations. +type Teams struct{ Client *transport.Client } + +// List lists teams in one organization. +func (s Teams) List(ctx context.Context, org string, limit, page int) ([]Team, error) { + teams, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Team, *gitea.Response, error) { + return c.Organizations.ListOrgTeams(ctx, org, gitea.ListTeamsOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(teams, teamFrom), nil +} + +// ByName indexes every team of an organization by lower-case name, which Gitea keeps unique. +func (s Teams) ByName(ctx context.Context, org string) (map[string]Team, error) { + teams, err := listAll(func(page int) ([]Team, error) { return s.List(ctx, org, 50, page) }) + if err != nil { + return nil, err + } + result := make(map[string]Team, len(teams)) + for _, team := range teams { + result[strings.ToLower(team.Name)] = team + } + return result, nil +} + +// View retrieves a team and its current permissions. +func (s Teams) View(ctx context.Context, id int64) (Team, error) { + team, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Team, *gitea.Response, error) { + return c.Organizations.GetTeam(ctx, id) + }) + if err != nil { + return Team{}, err + } + return teamFrom(team), nil +} + +// Create creates an organization team with explicit initial permissions. +func (s Teams) Create(ctx context.Context, org string, opts TeamOptions) (Team, error) { + team, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Team, *gitea.Response, error) { + return c.Organizations.CreateTeam(ctx, org, gitea.CreateTeamOption{ + Name: deref(opts.Name), Description: deref(opts.Description), Permission: gitea.AccessMode(deref(opts.Permission)), + CanCreateOrgRepo: deref(opts.CanCreateRepo), IncludesAllRepositories: deref(opts.AllRepositories), UnitsMap: opts.Units, + }) + }) + if err != nil { + return Team{}, err + } + return teamFrom(team), nil +} + +// Edit preserves unit permissions when changing access to all repositories. +func (s Teams) Edit(ctx context.Context, id int64, opts TeamOptions) (Team, error) { + if opts.AllRepositories != nil && opts.Permission == nil { + current, err := s.View(ctx, id) + if err != nil { + return Team{}, err + } + if current.Permission == "owner" { + return Team{}, &CapabilityError{Feature: "owner-team repository policy editing", Reason: "owner teams already manage all organization repositories"} + } + opts.Permission = ¤t.Permission + if opts.Units == nil { + opts.Units = current.Units + } + } + var team gitea.Team + // TODO(sdk): EditTeamOption requires a name of at most 30 characters and a permission, and EditTeam should return the team. + if err := sendJSON(ctx, s.Client, http.MethodPatch, fmt.Sprintf("teams/%d", id), opts, &team); err != nil { + return Team{}, err + } + return teamFrom(&team), nil +} + +// Delete deletes a team and its associated access grants. +func (s Teams) Delete(ctx context.Context, id int64) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + return c.Organizations.DeleteTeam(ctx, id) + }) +} + +// Members lists members of a team. +func (s Teams) Members(ctx context.Context, id int64, limit, page int) ([]OrganizationMember, error) { + users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { + return c.Organizations.ListTeamMembers(ctx, id, gitea.ListTeamMembersOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(users, memberFrom), nil +} + +// SetMember adds or removes one team membership. +func (s Teams) SetMember(ctx context.Context, id int64, username string, add bool) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + if add { + return c.Organizations.AddTeamMember(ctx, id, username) + } + return c.Organizations.RemoveTeamMember(ctx, id, username) + }) +} + +// Repositories lists repositories accessible to a team. +func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) ([]Repository, error) { + repos, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Repository, *gitea.Response, error) { + return c.Organizations.ListTeamRepositories(ctx, id, gitea.ListTeamRepositoriesOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + return nil, err + } + return convertAll(repos, repositoryFrom), nil +} + +// SetRepository adds or removes team access to a repository. +func (s Teams) SetRepository(ctx context.Context, id int64, repository string, add bool) error { + return repoDo(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { + if add { + return c.Organizations.AddTeamRepository(ctx, id, owner, name) + } + return c.Organizations.RemoveTeamRepository(ctx, id, owner, name) + }) +} diff --git a/internal/services/teams_test.go b/internal/services/teams_test.go new file mode 100644 index 00000000..ceca6e03 --- /dev/null +++ b/internal/services/teams_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestTeamRepositoryPolicyPreservesUnitPermissions(t *testing.T) { + mutations := 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/teams/1", r.URL.Path) + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"id":1,"permission":"read","units_map":{"repo.code":"write","repo.issues":"read"}}`)) + return + } + mutations++ + body := requestJSON(t, r) + require.Equal(t, "read", body["permission"]) + require.Equal(t, false, body["includes_all_repositories"]) + require.Equal(t, map[string]any{"repo.code": "write", "repo.issues": "read"}, body["units_map"]) + require.NotContains(t, body, "name") + require.NotContains(t, body, "description") + _, _ = w.Write([]byte(`{"id":1,"permission":"read","organization":{"username":"acme"}}`)) + })) + service := Teams{Client: client} + allRepos := false + item, err := service.Edit(context.Background(), 1, TeamOptions{AllRepositories: &allRepos}) + require.NoError(t, err) + require.Equal(t, "acme", item.Organization) + require.Equal(t, 1, mutations) +} + +func TestTeamCreateSendsPermissionAndUnits(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/orgs/acme/teams", r.URL.Path) + body := requestJSON(t, r) + require.Equal(t, "Developers", body["name"]) + require.Equal(t, "write", body["permission"]) + require.Equal(t, true, body["can_create_org_repo"]) + require.Equal(t, false, body["includes_all_repositories"]) + require.Equal(t, map[string]any{"repo.code": "write"}, body["units_map"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":3,"name":"Developers","permission":"write","can_create_org_repo":true,"units_map":{"repo.code":"write"},"organization":{"name":"acme"}}`)) + })) + name, permission, createRepo, allRepos := "Developers", "write", true, false + item, err := (Teams{Client: client}).Create(context.Background(), "acme", TeamOptions{ + Name: &name, Permission: &permission, CanCreateRepo: &createRepo, AllRepositories: &allRepos, Units: map[string]string{"repo.code": "write"}, + }) + require.NoError(t, err) + require.Equal(t, Team{ID: 3, Name: "Developers", Organization: "acme", Permission: "write", CanCreateRepo: true, Units: map[string]string{"repo.code": "write"}}, item) +} + +func TestTeamAccessRequests(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.RequestURI()) + switch { + case r.URL.Path == "/api/v1/teams/3/members": + _, _ = w.Write([]byte(`[{"id":2,"login":"bob"}]`)) + case r.URL.Path == "/api/v1/teams/3/repos": + _, _ = w.Write([]byte(`[{"id":4,"full_name":"acme/project","html_url":"https://gitea.test/acme/project"}]`)) + default: + w.WriteHeader(http.StatusNoContent) + } + })) + service := Teams{Client: client} + members, err := service.Members(context.Background(), 3, 5, 2) + require.NoError(t, err) + require.Equal(t, []OrganizationMember{{ID: 2, Username: "bob"}}, members) + repos, err := service.Repositories(context.Background(), 3, 5, 1) + require.NoError(t, err) + require.Equal(t, "https://gitea.test/acme/project", repos[0].URL) + require.NoError(t, service.SetMember(context.Background(), 3, "bob", false)) + require.NoError(t, service.SetRepository(context.Background(), 3, "acme/project", true)) + require.Equal(t, []string{ + "GET /api/v1/teams/3/members?limit=5&page=2", "GET /api/v1/teams/3/repos?limit=5&page=1", + "DELETE /api/v1/teams/3/members/bob", "PUT /api/v1/teams/3/repos/acme/project", + }, requests) +} + +func TestTeamFromKeepsAbsentValuesEmpty(t *testing.T) { + item := teamFrom(decode[gitea.Team](t, `{"id":1,"name":"Owners","permission":"owner","organization":null,"units_map":null}`)) + require.Equal(t, Team{ID: 1, Name: "Owners", Permission: "owner", Units: map[string]string{}}, item) +} diff --git a/internal/services/time.go b/internal/services/time.go new file mode 100644 index 00000000..8dcdf0be --- /dev/null +++ b/internal/services/time.go @@ -0,0 +1,106 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// TrackedTime is a stable record of seconds spent on an issue or pull request. +type TrackedTime struct { + ID int64 `json:"id"` + Seconds int64 `json:"seconds"` + CreatedAt string `json:"created_at"` + User string `json:"user"` + Repository string `json:"repository"` + IssueNumber int64 `json:"issue_number"` + IssueURL string `json:"issue_url"` +} + +// trackedTimeFrom falls back to repository when the record does not name its issue's repository. +func trackedTimeFrom(record *gitea.TrackedTime, repository string) TrackedTime { + result := TrackedTime{ID: record.ID, Seconds: record.Time, CreatedAt: timestamp(record.Created), User: record.UserName, Repository: repository} + if record.Issue != nil { + result.IssueNumber = record.Issue.Index + result.IssueURL = record.Issue.HTMLURL + if record.Issue.Repository != nil { + result.Repository = record.Issue.Repository.FullName + } + } + return result +} + +// TimeOptions supplies an explicit scope, pagination and date filters. +type TimeOptions struct { + Mine bool + Issue int64 + Username string + Limit, Page int + Since, Before *time.Time +} + +// Times implements repository and account time tracking. +type Times struct { + Client *transport.Client + Repository string +} + +// List returns a non-nil page of tracked time records. +func (s Times) List(ctx context.Context, opts TimeOptions) ([]TrackedTime, error) { + query := gitea.ListTrackedTimesOptions{ListOptions: listOptions(opts.Limit, opts.Page), User: opts.Username} + if opts.Since != nil { + query.Since = *opts.Since + } + if opts.Before != nil { + query.Before = *opts.Before + } + records, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.TrackedTime, *gitea.Response, error) { + if opts.Mine { + return c.Issues.ListMyTrackedTimes(ctx, query) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + if opts.Issue != 0 { + return c.Issues.ListIssueTrackedTimes(ctx, owner, repo, opts.Issue, query) + } + return c.Issues.ListRepoTrackedTimes(ctx, owner, repo, query) + }) + if err != nil { + return nil, err + } + return convertAll(records, func(record *gitea.TrackedTime) TrackedTime { return trackedTimeFrom(record, s.Repository) }), nil +} + +// Create records a nonzero number of seconds on one issue. +func (s Times) Create(ctx context.Context, n, seconds int64) (TrackedTime, error) { + record, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.TrackedTime, *gitea.Response, error) { + return c.Issues.AddTime(ctx, owner, repo, n, gitea.AddTimeOption{Time: seconds}) + }) + if err != nil { + return TrackedTime{}, err + } + result := trackedTimeFrom(record, s.Repository) + result.IssueNumber = n + return result, nil +} + +// Delete removes one tracked time record from the selected issue. +func (s Times) Delete(ctx context.Context, n, id int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Issues.DeleteTime(ctx, owner, repo, n, id) + }) +} + +// Reset removes all tracked time records on the selected issue. +func (s Times) Reset(ctx context.Context, n int64) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Issues.ResetIssueTime(ctx, owner, repo, n) + }) +} diff --git a/internal/services/time_test.go b/internal/services/time_test.go new file mode 100644 index 00000000..9b6ac28d --- /dev/null +++ b/internal/services/time_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "net/http" + "net/url" + "testing" + "time" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTrackedTimeFromFallsBackToSelectedRepository(t *testing.T) { + require.Equal(t, TrackedTime{ID: 1, Seconds: 30, Repository: "alice/project"}, + trackedTimeFrom(decode[gitea.TrackedTime](t, `{"id":1,"time":30,"issue":null}`), "alice/project")) + require.Equal(t, TrackedTime{ID: 2, CreatedAt: "2026-10-01T12:00:00Z", Repository: "alice/project", IssueNumber: 4}, + trackedTimeFrom(decode[gitea.TrackedTime](t, `{"id":2,"created":"2026-10-01T12:00:00Z","issue":{"number":4,"repository":null}}`), "alice/project")) +} + +func TestTimeListSelectsScopeAndFilters(t *testing.T) { + since := time.Date(2026, 10, 1, 8, 0, 0, 0, time.FixedZone("CEST", 2*60*60)) + before := time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC) + for name, test := range map[string]struct { + opts TimeOptions + wantPath string + wantQuery url.Values + }{ + "repository": { + opts: TimeOptions{Limit: 30, Page: 1}, + wantPath: "/api/v1/repos/alice/project/times", + wantQuery: url.Values{"limit": {"30"}, "page": {"1"}}, + }, + "issue with filters": { + opts: TimeOptions{Issue: 7, Username: "bob", Since: &since, Before: &before, Limit: 10, Page: 2}, + wantPath: "/api/v1/repos/alice/project/issues/7/times", + wantQuery: url.Values{"limit": {"10"}, "page": {"2"}, "user": {"bob"}, "since": {"2026-10-01T08:00:00+02:00"}, "before": {"2026-10-02T00:00:00Z"}}, + }, + "mine wins over issue": { + opts: TimeOptions{Mine: true, Issue: 7, Limit: 30, Page: 1}, + wantPath: "/api/v1/user/times", + wantQuery: url.Values{"limit": {"30"}, "page": {"1"}}, + }, + } { + t.Run(name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.wantPath, r.URL.Path) + assert.Equal(t, test.wantQuery, r.URL.Query()) + _, _ = w.Write([]byte(`[ + {"id":1,"time":60,"user_name":"bob","issue":{"number":7,"html_url":"https://example.test/7"}}, + {"id":2,"time":30,"issue":{"number":3,"repository":{"full_name":"carol/other"}}} + ]`)) + })) + items, err := (Times{Client: client, Repository: "alice/project"}).List(context.Background(), test.opts) + require.NoError(t, err) + require.Equal(t, []TrackedTime{ + {ID: 1, Seconds: 60, User: "bob", Repository: "alice/project", IssueNumber: 7, IssueURL: "https://example.test/7"}, + {ID: 2, Seconds: 30, Repository: "carol/other", IssueNumber: 3}, + }, items) + }) + } +} diff --git a/internal/services/webhooks.go b/internal/services/webhooks.go new file mode 100644 index 00000000..3986e6a3 --- /dev/null +++ b/internal/services/webhooks.go @@ -0,0 +1,212 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "maps" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// Webhook is safe webhook metadata. Delivery URLs and credentials are omitted. +type Webhook struct { + ID int64 `json:"id"` + Type string `json:"type"` + Active bool `json:"active"` + Events []string `json:"events"` + BranchFilter string `json:"branch_filter"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func webhookFrom(hook *gitea.Hook) Webhook { + events := hook.Events + if events == nil { + events = []string{} + } + return Webhook{ + ID: hook.ID, Type: hook.Type, Active: hook.Active, Events: events, BranchFilter: hook.BranchFilter, + CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated), + } +} + +// WebhookScope selects one repository, organization, account or system scope. +type WebhookScope struct { + Repository, Organization string + Account, Global bool +} + +// WebhookOptions distinguishes omitted input from explicit empty or false values. +type WebhookOptions struct { + Type string `json:"type,omitempty"` + Config map[string]string `json:"config,omitempty"` + Events *[]string `json:"events,omitempty"` + Active *bool `json:"active,omitempty"` + BranchFilter *string `json:"branch_filter,omitempty"` + Authorization *string `json:"authorization_header,omitempty"` +} + +// Webhooks manages hooks without returning delivery credentials. +type Webhooks struct { + Client *transport.Client + Scope WebhookScope +} + +// List returns a page of hook metadata. +func (s Webhooks) List(ctx context.Context, limit, page int) ([]Webhook, error) { + hooks, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, error) { + opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)} + switch { + case s.Scope.Global: + return c.Hooks.ListGlobalHooks(ctx, gitea.ListGlobalHooksOptions{ListOptions: opts.ListOptions}) + case s.Scope.Account: + return c.Hooks.ListMyHooks(ctx, opts) + case s.Scope.Organization != "": + return c.Hooks.ListOrgHooks(ctx, s.Scope.Organization, opts) + } + owner, name, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, nil, err + } + return c.Hooks.ListRepoHooks(ctx, owner, name, opts) + }) + if err != nil { + return nil, err + } + return convertAll(hooks, webhookFrom), nil +} + +func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) { + hook, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Hook, *gitea.Response, error) { + switch { + case s.Scope.Global: + return c.Hooks.GetGlobalHook(ctx, id) + case s.Scope.Account: + return c.Hooks.GetMyHook(ctx, id) + case s.Scope.Organization != "": + return c.Hooks.GetOrgHook(ctx, s.Scope.Organization, id) + } + owner, name, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, nil, err + } + return c.Hooks.GetRepoHook(ctx, owner, name, id) + }) + return hook, err +} + +// View retrieves hook metadata without exposing its delivery configuration. +func (s Webhooks) View(ctx context.Context, id int64) (Webhook, error) { + hook, err := s.get(ctx, id) + if err != nil { + return Webhook{}, err + } + return webhookFrom(hook), nil +} + +// Create registers a new webhook. +func (s Webhooks) Create(ctx context.Context, opts WebhookOptions) (Webhook, error) { + create := gitea.CreateHookOption{ + Type: gitea.HookType(opts.Type), Config: opts.Config, Events: deref(opts.Events), BranchFilter: deref(opts.BranchFilter), + Active: deref(opts.Active), AuthorizationHeader: deref(opts.Authorization), + } + if s.Scope.Global { + create.Config = make(map[string]string, len(opts.Config)+1) + maps.Copy(create.Config, opts.Config) + create.Config["is_system_webhook"] = "true" + } + hook, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Hook, *gitea.Response, error) { + switch { + case s.Scope.Global: + return c.Hooks.CreateGlobalHook(ctx, create) + case s.Scope.Account: + return c.Hooks.CreateMyHook(ctx, create) + case s.Scope.Organization != "": + return c.Hooks.CreateOrgHook(ctx, s.Scope.Organization, create) + } + owner, name, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, nil, err + } + return c.Hooks.CreateRepoHook(ctx, owner, name, create) + }) + if err != nil { + return Webhook{}, err + } + return webhookFrom(hook), nil +} + +// Edit preserves settings that Gitea replaces rather than patching individually. +func (s Webhooks) Edit(ctx context.Context, id int64, opts WebhookOptions) (Webhook, error) { + if _, ok := opts.Config["secret"]; ok { + return Webhook{}, &CapabilityError{Feature: "webhook secret editing", Reason: "Gitea's edit API does not update the signing secret; recreate the hook to rotate it"} + } + if opts.Authorization == nil { + return Webhook{}, &CapabilityError{Feature: "webhook editing without an authorization choice", Reason: "Gitea replaces authorization and can hide its existing value; supply --authorization-header-file or --clear-authorization"} + } + current, err := s.get(ctx, id) + if err != nil { + return Webhook{}, err + } + edit := gitea.EditHookOption{Events: current.Events, BranchFilter: current.BranchFilter, Active: opts.Active, AuthorizationHeader: *opts.Authorization} + if opts.Events != nil { + edit.Events = *opts.Events + } + if opts.BranchFilter != nil { + edit.BranchFilter = *opts.BranchFilter + } + // Adapter-specific configuration can be replaced as a group by the server. + if opts.Config != nil { + edit.Config = make(map[string]string, len(current.Config)+len(opts.Config)) + maps.Copy(edit.Config, current.Config) + maps.Copy(edit.Config, opts.Config) + } + hook, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Hook, *gitea.Response, error) { + var resp *gitea.Response + var err error + switch { + case s.Scope.Global: + return c.Hooks.EditGlobalHook(ctx, id, edit) + case s.Scope.Account: + resp, err = c.Hooks.EditMyHook(ctx, id, edit) + case s.Scope.Organization != "": + resp, err = c.Hooks.EditOrgHook(ctx, s.Scope.Organization, id, edit) + default: + owner, name, splitErr := splitRepository(s.Scope.Repository) + if splitErr != nil { + return nil, nil, splitErr + } + resp, err = c.Hooks.EditRepoHook(ctx, owner, name, id, edit) + } + return nil, resp, err + }) + if err == nil && hook == nil { + hook, err = s.get(ctx, id) + } + if err != nil { + return Webhook{}, err + } + return webhookFrom(hook), nil +} + +// Delete removes a webhook from the selected scope. +func (s Webhooks) Delete(ctx context.Context, id int64) error { + return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { + switch { + case s.Scope.Global: + return c.Hooks.DeleteGlobalHook(ctx, id) + case s.Scope.Account: + return c.Hooks.DeleteMyHook(ctx, id) + case s.Scope.Organization != "": + return c.Hooks.DeleteOrgHook(ctx, s.Scope.Organization, id) + } + owner, name, err := splitRepository(s.Scope.Repository) + if err != nil { + return nil, err + } + return c.Hooks.DeleteRepoHook(ctx, owner, name, id) + }) +} diff --git a/internal/services/webhooks_test.go b/internal/services/webhooks_test.go new file mode 100644 index 00000000..8ab74399 --- /dev/null +++ b/internal/services/webhooks_test.go @@ -0,0 +1,150 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebhookEditPreservesSettingsAndRedactsCredentials(t *testing.T) { + mutations, reads := 0, 0 + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/owner/repo/hooks/1", r.URL.Path) + if r.Method == http.MethodGet { + reads++ + if mutations > 0 { + _, _ = w.Write([]byte(`{"id":1,"type":"slack","active":false,"events":["issues","release"],"authorization_header":"private-header","config":{"secret":"private-secret","url":"https://hook.invalid/private-path"},"updated_at":"2026-10-02T12:00:00Z"}`)) + return + } + _, _ = w.Write([]byte(`{"id":1,"type":"slack","active":true,"events":["issues","release"],"branch_filter":"main","authorization_header":"private-header","config":{"url":"https://hook.invalid/private-path","channel":"old","username":"robot","secret":"private-secret"}}`)) + return + } + assert.Equal(t, http.MethodPatch, r.Method) + mutations++ + var payload map[string]any + if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Equal(t, []any{"issues", "release"}, payload["events"]) + assert.Equal(t, "main", payload["branch_filter"]) + assert.Equal(t, "", payload["authorization_header"]) + assert.Equal(t, false, payload["active"]) + assert.Equal(t, map[string]any{"url": "https://hook.invalid/private-path", "channel": "new", "username": "robot", "secret": "private-secret"}, payload["config"]) + w.WriteHeader(http.StatusOK) + })) + service := Webhooks{Client: client, Scope: WebhookScope{Repository: "owner/repo"}} + active := false + authorization := "" + item, err := service.Edit(context.Background(), 1, WebhookOptions{Active: &active, Authorization: &authorization, Config: map[string]string{"channel": "new"}}) + require.NoError(t, err) + data, err := json.Marshal(item) + require.NoError(t, err) + require.NotContains(t, string(data), "private") + require.NotContains(t, string(data), "config") + require.False(t, item.Active) + require.Equal(t, "2026-10-02T12:00:00Z", item.UpdatedAt) + require.Equal(t, 1, mutations) + require.Equal(t, 2, reads) + _, err = service.Edit(context.Background(), 1, WebhookOptions{Active: &active}) + require.ErrorContains(t, err, "authorization choice") + _, err = service.Edit(context.Background(), 1, WebhookOptions{Authorization: &authorization, Config: map[string]string{"secret": "new"}}) + require.ErrorContains(t, err, "signing secret") + require.Equal(t, 1, mutations) +} + +func TestWebhookCreateMarksOnlyGlobalHooksAsSystem(t *testing.T) { + for name, test := range map[string]struct { + scope WebhookScope + wantPath string + wantConfig map[string]any + }{ + "global": {scope: WebhookScope{Global: true}, wantPath: "/api/v1/admin/hooks", wantConfig: map[string]any{"url": "https://hook.invalid", "is_system_webhook": "true"}}, + "organization": {scope: WebhookScope{Organization: "team"}, wantPath: "/api/v1/orgs/team/hooks", wantConfig: map[string]any{"url": "https://hook.invalid"}}, + "repository": {scope: WebhookScope{Repository: "owner/repo"}, wantPath: "/api/v1/repos/owner/repo/hooks", wantConfig: map[string]any{"url": "https://hook.invalid"}}, + } { + t.Run(name, func(t *testing.T) { + var payload map[string]any + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, test.wantPath, r.URL.Path) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + _, _ = w.Write([]byte(`{"id":4,"type":"gitea","active":true,"config":{"url":"https://hook.invalid"}}`)) + })) + config := map[string]string{"url": "https://hook.invalid"} + item, err := (Webhooks{Client: client, Scope: test.scope}).Create(context.Background(), WebhookOptions{Type: "gitea", Config: config}) + require.NoError(t, err) + require.EqualValues(t, 4, item.ID) + require.Equal(t, []string{}, item.Events) + require.Equal(t, test.wantConfig, payload["config"]) + require.Equal(t, map[string]string{"url": "https://hook.invalid"}, config, "caller's config must not change") + }) + } +} + +func TestWebhookGlobalEditUsesReturnedHook(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + var payload map[string]any + if r.Method == http.MethodPatch { + assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, []any{"push"}, payload["events"]) + assert.Nil(t, payload["config"]) + assert.Nil(t, payload["active"]) + } + _, _ = w.Write([]byte(`{"id":2,"type":"gitea","active":true,"events":["push"]}`)) + })) + authorization := "Bearer value" + item, err := (Webhooks{Client: client, Scope: WebhookScope{Global: true}}).Edit(context.Background(), 2, WebhookOptions{Authorization: &authorization}) + require.NoError(t, err) + require.Equal(t, Webhook{ID: 2, Type: "gitea", Active: true, Events: []string{"push"}}, item) + require.Equal(t, []string{"GET /api/v1/admin/hooks/2", "PATCH /api/v1/admin/hooks/2"}, requests) +} + +func TestWebhookScopesSelectEndpoints(t *testing.T) { + for name, tc := range map[string]struct { + scope WebhookScope + path string + }{ + "global": {scope: WebhookScope{Global: true}, path: "/api/v1/admin/hooks"}, + "account": {scope: WebhookScope{Account: true}, path: "/api/v1/user/hooks"}, + "organization": {scope: WebhookScope{Organization: "team"}, path: "/api/v1/orgs/team/hooks"}, + "repository": {scope: WebhookScope{Repository: "owner/repo"}, path: "/api/v1/repos/owner/repo/hooks"}, + } { + t.Run(name, func(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.RequestURI()) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`[{"id":3,"type":"gitea","events":null}]`)) + })) + service := Webhooks{Client: client, Scope: tc.scope} + items, err := service.List(context.Background(), 10, 2) + require.NoError(t, err) + require.Equal(t, []Webhook{{ID: 3, Type: "gitea", Events: []string{}}}, items) + require.NoError(t, service.Delete(context.Background(), 3)) + require.Equal(t, []string{"GET " + tc.path + "?limit=10&page=2", "DELETE " + tc.path + "/3"}, requests) + }) + } +} + +func TestWebhookFromMapsTimestamps(t *testing.T) { + hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","active":true,"events":["push"],"branch_filter":"main", + "created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","config":{"url":"https://private.invalid"}}`) + require.Equal(t, Webhook{ + ID: 1, Type: "gitea", Active: true, Events: []string{"push"}, BranchFilter: "main", + CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00", + }, webhookFrom(hook)) +} diff --git a/internal/services/wiki.go b/internal/services/wiki.go new file mode 100644 index 00000000..be15fd6d --- /dev/null +++ b/internal/services/wiki.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/internal/transport" +) + +// WikiPage exposes decoded page content and revision metadata. +type WikiPage struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + SHA string `json:"sha"` + Author string `json:"author"` + UpdatedAt string `json:"updated_at"` + CommitCount int64 `json:"commit_count"` + Sidebar string `json:"sidebar"` + Footer string `json:"footer"` +} + +// WikiRevision represents a wiki commit without exposing author email addresses. +type WikiRevision struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` +} + +func wikiRevisionFrom(commit *gitea.WikiCommit) WikiRevision { + result := WikiRevision{SHA: commit.ID, Message: commit.Message} + if commit.Author != nil { + result.Author = commit.Author.Name + result.CreatedAt = commit.Author.Date + } + return result +} + +func wikiPageFrom(title, url string, lastCommit *gitea.WikiCommit) WikiPage { + result := WikiPage{Title: title, URL: url} + if lastCommit != nil { + revision := wikiRevisionFrom(lastCommit) + result.SHA, result.Author, result.UpdatedAt = revision.SHA, revision.Author, revision.CreatedAt + } + return result +} + +func wikiContentFrom(page *gitea.WikiPage) (WikiPage, error) { + body, err := base64.StdEncoding.DecodeString(page.ContentBase64) + if err != nil { + return WikiPage{}, fmt.Errorf("decode wiki content: %w", err) + } + result := wikiPageFrom(page.Title, page.HTMLURL, page.LastCommit) + result.Body, result.CommitCount, result.Sidebar, result.Footer = string(body), page.CommitCount, page.Sidebar, page.Footer + return result, nil +} + +// WikiOptions supplies title, decoded content and a commit message. +type WikiOptions struct{ Title, Body, Message string } + +// Wiki implements repository wiki operations. +type Wiki struct { + Client *transport.Client + Repository string +} + +func (s Wiki) page(ctx context.Context, op repoOperation[*gitea.WikiPage]) (WikiPage, error) { + page, err := repoCall(ctx, s.Client, s.Repository, op) + if err != nil { + return WikiPage{}, err + } + return wikiContentFrom(page) +} + +// List returns a non-nil page of wiki metadata. +func (s Wiki) List(ctx context.Context, limit, page int) ([]WikiPage, error) { + pages, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.WikiPageMetaData, *gitea.Response, error) { + return c.Wiki.ListPages(ctx, owner, repo, gitea.ListWikiPagesOptions{ListOptions: listOptions(limit, page)}) + }) + if err != nil { + if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound { + return nil, err + } + // An uninitialized wiki returns 404. Verify repository visibility and + // wiki configuration before treating that response as an empty list. + repository, checkErr := (Repositories{Client: s.Client}).get(ctx, s.Repository) + if checkErr != nil { + return nil, checkErr + } + if !repository.HasWiki { + return nil, err + } + return []WikiPage{}, nil + } + return convertAll(pages, func(item *gitea.WikiPageMetaData) WikiPage { + return wikiPageFrom(item.Title, item.HTMLURL, item.LastCommit) + }), nil +} + +// View retrieves and decodes a wiki page. +func (s Wiki) View(ctx context.Context, name string) (WikiPage, error) { + return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) { + return c.Wiki.GetPage(ctx, owner, repo, name) + }) +} + +func wikiOptions(opts WikiOptions) gitea.CreateWikiPageOptions { + return gitea.CreateWikiPageOptions{Title: opts.Title, ContentBase64: base64.StdEncoding.EncodeToString([]byte(opts.Body)), Message: opts.Message} +} + +// Create creates one wiki page, initializing the wiki if necessary. +func (s Wiki) Create(ctx context.Context, opts WikiOptions) (WikiPage, error) { + return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) { + return c.Wiki.CreatePage(ctx, owner, repo, wikiOptions(opts)) + }) +} + +// Edit replaces page content and optionally changes its title. +func (s Wiki) Edit(ctx context.Context, name string, opts WikiOptions) (WikiPage, error) { + if opts.Title == "" { + current, err := s.View(ctx, name) + if err != nil { + return WikiPage{}, err + } + opts.Title = current.Title + } + return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) { + return c.Wiki.EditPage(ctx, owner, repo, name, wikiOptions(opts)) + }) +} + +// Delete removes a named wiki page. +func (s Wiki) Delete(ctx context.Context, name string) error { + return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return c.Wiki.DeletePage(ctx, owner, repo, name) + }) +} + +// Revisions returns one server-paginated page of wiki commits. +func (s Wiki) Revisions(ctx context.Context, name string, page int) ([]WikiRevision, error) { + revisions, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiCommitList, *gitea.Response, error) { + return c.Wiki.GetPageRevisions(ctx, owner, repo, name, gitea.ListWikiPageRevisionsOptions{Page: page}) + }) + if err != nil { + return nil, err + } + return convertAll(revisions.WikiCommits, wikiRevisionFrom), nil +} diff --git a/internal/services/wiki_test.go b/internal/services/wiki_test.go new file mode 100644 index 00000000..6e3dfc9d --- /dev/null +++ b/internal/services/wiki_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWikiContentAndNamedPagePath(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/alice/project/wiki/page/Name with spaces", r.URL.Path) + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"title":"Name with spaces"}`)) + return + } + assert.Equal(t, http.MethodPatch, r.Method) + var body map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("ünicode\ntext")), body["content_base64"]) + assert.Equal(t, "Name with spaces", body["title"]) + _, _ = w.Write([]byte(`{"title":"Name with spaces","content_base64":"w7xuaWNvZGUKdGV4dA==","last_commit":{"sha":"abc","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}}}`)) + })) + service := Wiki{Client: client, Repository: "alice/project"} + item, err := service.Edit(context.Background(), "Name with spaces", WikiOptions{Body: "ünicode\ntext"}) + require.NoError(t, err) + require.Equal(t, "ünicode\ntext", item.Body) + require.Equal(t, "Alice", item.Author) + data, err := json.Marshal(item) + require.NoError(t, err) + require.NotContains(t, string(data), "author@example.test") +} + +func TestWikiMalformedContentAndCancellation(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"content_base64":"invalid!"}`)) })) + service := Wiki{Client: client, Repository: "alice/project"} + _, err := service.View(context.Background(), "Home") + require.ErrorContains(t, err, "decode wiki content") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = service.View(ctx, "Home") + require.ErrorIs(t, err, context.Canceled) +} + +func TestWikiEmptyListDoesNotHideRepositoryErrors(t *testing.T) { + for name, wikiEnabled := range map[string]bool{"enabled": true, "disabled": false} { + t.Run(name, func(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/alice/project" { + _ = json.NewEncoder(w).Encode(map[string]bool{"has_wiki": wikiEnabled}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + items, err := (Wiki{Client: client, Repository: "alice/project"}).List(context.Background(), 30, 1) + if !wikiEnabled { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, items) + require.Empty(t, items) + }) + } +} + +func TestWikiListAndRevisionsMapCommits(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/repos/alice/project/wiki/pages": + assert.Equal(t, "5", r.URL.Query().Get("limit")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`[{"title":"Home","html_url":"https://gitea.test/wiki/Home","last_commit":{"sha":"abc","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}}},{"title":"Draft"}]`)) + case "/api/v1/repos/alice/project/wiki/revisions/Home": + assert.Equal(t, "3", r.URL.Query().Get("page")) + _, _ = w.Write([]byte(`{"commits":[{"sha":"abc","message":"Edit","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}},{"sha":"def","author":null}],"count":2}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + service := Wiki{Client: client, Repository: "alice/project"} + pages, err := service.List(context.Background(), 5, 2) + require.NoError(t, err) + require.Equal(t, []WikiPage{ + {Title: "Home", URL: "https://gitea.test/wiki/Home", SHA: "abc", Author: "Alice", UpdatedAt: "2026-10-04T00:00:00Z"}, + {Title: "Draft"}, + }, pages) + revisions, err := service.Revisions(context.Background(), "Home", 3) + require.NoError(t, err) + require.Equal(t, []WikiRevision{{SHA: "abc", Message: "Edit", Author: "Alice", CreatedAt: "2026-10-04T00:00:00Z"}, {SHA: "def"}}, revisions) +} + +func TestWikiCreateAndDelete(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + assert.Equal(t, "/api/v1/repos/alice/project/wiki/page/Old%20page", r.URL.EscapedPath()) + w.WriteHeader(http.StatusNoContent) + return + } + assert.Equal(t, "/api/v1/repos/alice/project/wiki/new", r.URL.Path) + var body map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, map[string]string{"title": "New", "content_base64": "Ym9keQ==", "message": "Add"}, body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"title":"New","content_base64":"Ym9keQ==","commit_count":1,"sidebar":"side","footer":"foot"}`)) + })) + service := Wiki{Client: client, Repository: "alice/project"} + page, err := service.Create(context.Background(), WikiOptions{Title: "New", Body: "body", Message: "Add"}) + require.NoError(t, err) + require.Equal(t, WikiPage{Title: "New", Body: "body", CommitCount: 1, Sidebar: "side", Footer: "foot"}, page) + require.NoError(t, service.Delete(context.Background(), "Old page")) +}