mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add services layer on the Gitea Go SDK (#1125)
- `internal/services`: typed Gitea workflows built on `gitea.dev/sdk`, independent of CLI flags and terminal I/O: issues, pull requests, reviews, comments, labels, milestones, releases and assets, repositories, branches, wiki, Actions (workflows, runs, jobs, logs, artifacts, secrets, variables), organizations, teams, webhooks, notifications, SSH keys, tracked time, admin users, search and status. - SDK types are mapped to tea's own output types, so `--json` stays a stable contract. Requests the SDK cannot express yet (e.g. pull list filters, multi-status run lists, org label archiving) go through small raw helpers, each marked `TODO(sdk)` with the missing SDK piece. - Targets Gitea 28 or newer: no version or API-schema probing. List helpers fill pages capped by `MAX_RESPONSE_ITEMS`. Nothing imports this package yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1125 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
faea86cd94
commit
12e415a7a7
80 files changed
+9512
No files matched your search
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{`<https://example.org/items?a=1,2>; title="one,two"; rel="prev next", </last>; rel=last`})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"https://example.org/items?a=1,2"}, links)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`)))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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", `<html>oops</html>`, "", 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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user