feat(gitea): client core, fake server and repo endpoints
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
|
||||||
|
// exactly one of these plus an "http NNN" code in the message.
|
||||||
|
var (
|
||||||
|
ErrUnreachable = errors.New("gitea: unreachable")
|
||||||
|
ErrMalformedResponse = errors.New("gitea: malformed response")
|
||||||
|
ErrAuth = errors.New("gitea: auth failed")
|
||||||
|
ErrNotFound = errors.New("gitea: not found")
|
||||||
|
ErrValidation = errors.New("gitea: validation failed")
|
||||||
|
ErrServer = errors.New("gitea: server error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Client.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string // e.g. https://git.knownelement.com (no trailing slash needed)
|
||||||
|
Token string // Gitea API token; header-only, never logged
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a Gitea API v1 client. Safe for concurrent use.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. A zero Timeout means 30s.
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||||
|
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs one JSON exchange against /api/v1. out may be nil for
|
||||||
|
// empty bodies.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if in != nil {
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
body = strings.NewReader(string(b))
|
||||||
|
}
|
||||||
|
u := c.cfg.BaseURL + "/api/v1" + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.cfg.Token) // token lives here and only here
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if in != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Transport errors embed URLs and peer text; drop them all.
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnreachable
|
||||||
|
}
|
||||||
|
return handleResponse(resp.StatusCode, raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
|
||||||
|
// are never surfaced: a server echo is assumed to be able to contain the
|
||||||
|
// token (see the fakegitea package, which deliberately echoes it).
|
||||||
|
func handleResponse(status int, body []byte, out any) error {
|
||||||
|
switch {
|
||||||
|
case status >= 200 && status < 300:
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return statusError(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
|
||||||
|
func statusError(status int) error {
|
||||||
|
switch {
|
||||||
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||||
|
case status == http.StatusNotFound:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrNotFound, status)
|
||||||
|
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrValidation, status)
|
||||||
|
case status >= 500:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is the account projection repos and PRs carry.
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
FullName string `json:"full_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo is the repository projection (read shape).
|
||||||
|
type Repo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Owner User `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoParams is the repo-create write shape. Owner routes the create:
|
||||||
|
// empty creates under the authenticated account (POST /user/repos), any
|
||||||
|
// other value creates under that organization (POST /orgs/{owner}/repos).
|
||||||
|
type RepoParams struct {
|
||||||
|
Owner string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Private bool
|
||||||
|
AutoInit bool
|
||||||
|
DefaultBranch string
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoWrite struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
AutoInit bool `json:"auto_init"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepo creates a repository and returns it.
|
||||||
|
func (c *Client) CreateRepo(ctx context.Context, p RepoParams) (*Repo, error) {
|
||||||
|
path := "/user/repos"
|
||||||
|
if p.Owner != "" {
|
||||||
|
path = "/orgs/" + url.PathEscape(p.Owner) + "/repos"
|
||||||
|
}
|
||||||
|
var out Repo
|
||||||
|
if err := c.do(ctx, "POST", path, nil, repoWrite{
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Private: p.Private,
|
||||||
|
AutoInit: p.AutoInit,
|
||||||
|
DefaultBranch: p.DefaultBranch,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepos lists repositories. An empty owner lists the authenticated
|
||||||
|
// account's own repos (GET /user/repos); otherwise the named account's
|
||||||
|
// or organization's (GET /users/{owner}/repos).
|
||||||
|
func (c *Client) ListRepos(ctx context.Context, owner string) ([]Repo, error) {
|
||||||
|
path := "/user/repos"
|
||||||
|
if owner != "" {
|
||||||
|
path = "/users/" + url.PathEscape(owner) + "/repos"
|
||||||
|
}
|
||||||
|
var out []Repo
|
||||||
|
if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepo fetches one repository.
|
||||||
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||||
|
var out Repo
|
||||||
|
if err := c.do(ctx, "GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), nil, nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.knownelement.com/ukrrs/mopac-gitea-go/gitea"
|
||||||
|
"git.knownelement.com/ukrrs/mopac-gitea-go/internal/fakegitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fakeToken = "fake-gitea-token-0123456789"
|
||||||
|
|
||||||
|
func newClient(t *testing.T) (*gitea.Client, *fakegitea.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv := fakegitea.New(fakeToken, "ukrrs")
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: fakeToken, Timeout: 5 * time.Second})
|
||||||
|
return c, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transport core: auth, error mapping, redaction --------------------------
|
||||||
|
|
||||||
|
func TestGetRepoRoundTrip(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota")
|
||||||
|
|
||||||
|
repo, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRepo: %v", err)
|
||||||
|
}
|
||||||
|
if repo.FullName != "ukrrs/MOPAC" || repo.DefaultBranch != "main" || repo.Owner.Login != "ukrrs" {
|
||||||
|
t.Fatalf("repo = %+v", repo)
|
||||||
|
}
|
||||||
|
reqs := srv.Requests()
|
||||||
|
if len(reqs) != 1 || reqs[0].Method != "GET" || reqs[0].Path != "/api/v1/repos/ukrrs/MOPAC" {
|
||||||
|
t.Fatalf("requests = %+v", reqs)
|
||||||
|
}
|
||||||
|
// The token lives in the Authorization header only: never in the
|
||||||
|
// path, query, or body the server recorded.
|
||||||
|
for _, r := range reqs {
|
||||||
|
if strings.Contains(r.Path+r.Query+r.Body, fakeToken) {
|
||||||
|
t.Fatalf("token leaked into request: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorMapping(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
wantSent error
|
||||||
|
}{
|
||||||
|
{401, `{"message":"bad credentials: token %s"}`, gitea.ErrAuth},
|
||||||
|
{403, `{"message":"forbidden for token %s"}`, gitea.ErrAuth},
|
||||||
|
{404, `{"message":"The target couldn't be found. Echo %s"}`, gitea.ErrNotFound},
|
||||||
|
{409, `{"message":"conflict, token %s"}`, gitea.ErrValidation},
|
||||||
|
{422, `{"message":"validation failed for %s"}`, gitea.ErrValidation},
|
||||||
|
{500, `{"message":"server exploded with token %s"}`, gitea.ErrServer},
|
||||||
|
{503, `{"message":"down for %s"}`, gitea.ErrServer},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run("http_"+strconv.Itoa(tt.status), func(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &fakegitea.FailSpec{Status: tt.status, Body: tt.body}
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error for http %d", tt.status)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantSent) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantSent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "http "+strconv.Itoa(tt.status)) {
|
||||||
|
t.Fatalf("err = %q, want http code in message", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenNeverLeaks(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &fakegitea.FailSpec{Status: 401, Body: `{"message":"token %s was rejected"}`}
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if err == nil || !errors.Is(err, gitea.ErrAuth) {
|
||||||
|
t.Fatalf("err = %v, want auth failure", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), fakeToken) {
|
||||||
|
t.Fatalf("error leaks the token: %q", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "was rejected") {
|
||||||
|
t.Fatalf("error leaks response body: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachable(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Close() // port gone; every call must map to ErrUnreachable, not panic
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if !errors.Is(err, gitea.ErrUnreachable) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedResponse(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.Fail = &fakegitea.FailSpec{Status: 200, Body: `<html>not json</html>`}
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if !errors.Is(err, gitea.ErrMalformedResponse) {
|
||||||
|
t.Fatalf("err = %v, want ErrMalformedResponse", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadTokenRejected(t *testing.T) {
|
||||||
|
srv := fakegitea.New(fakeToken, "ukrrs")
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := gitea.New(gitea.Config{BaseURL: srv.URL, Token: "wrong-token-abcdef"})
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "MOPAC")
|
||||||
|
if !errors.Is(err, gitea.ErrAuth) {
|
||||||
|
t.Fatalf("err = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "wrong-token-abcdef") {
|
||||||
|
t.Fatalf("presented token echoed in error: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- repos ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCreateRepoRoundTrip(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
|
||||||
|
repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{
|
||||||
|
Owner: "",
|
||||||
|
Name: "vertical-x",
|
||||||
|
Description: "one vertical",
|
||||||
|
Private: true,
|
||||||
|
AutoInit: true,
|
||||||
|
DefaultBranch: "main",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRepo: %v", err)
|
||||||
|
}
|
||||||
|
if repo.FullName != "ukrrs/vertical-x" || !repo.Private || repo.DefaultBranch != "main" {
|
||||||
|
t.Fatalf("repo = %+v", repo)
|
||||||
|
}
|
||||||
|
// POST /user/repos carries the write shape (scalars only).
|
||||||
|
var post map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil {
|
||||||
|
t.Fatalf("POST body not json: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"name", "description", "private", "auto_init", "default_branch"} {
|
||||||
|
if _, ok := post[k]; !ok {
|
||||||
|
t.Errorf("POST body missing %q: %v", k, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if post["name"] != "vertical-x" || post["private"] != true {
|
||||||
|
t.Errorf("POST body = %v", post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepoInOrg(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{Owner: "ukrrs-labs", Name: "org-repo"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRepo: %v", err)
|
||||||
|
}
|
||||||
|
if repo.Owner.Login != "ukrrs-labs" || repo.FullName != "ukrrs-labs/org-repo" {
|
||||||
|
t.Fatalf("repo = %+v", repo)
|
||||||
|
}
|
||||||
|
if reqs := srv.Requests(); reqs[0].Path != "/api/v1/orgs/ukrrs-labs/repos" {
|
||||||
|
t.Fatalf("path = %s, want orgs endpoint", reqs[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepoDuplicateConflict(t *testing.T) {
|
||||||
|
c, _ := newClient(t)
|
||||||
|
if _, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup", AutoInit: true}); err != nil {
|
||||||
|
t.Fatalf("first create: %v", err)
|
||||||
|
}
|
||||||
|
_, err := c.CreateRepo(context.Background(), gitea.RepoParams{Name: "dup"})
|
||||||
|
if !errors.Is(err, gitea.ErrValidation) { // 409 -> validation sentinel
|
||||||
|
t.Fatalf("err = %v, want ErrValidation (409)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRepos(t *testing.T) {
|
||||||
|
c, srv := newClient(t)
|
||||||
|
srv.AddRepo("ukrrs", "alpha", "main", "main")
|
||||||
|
srv.AddRepo("ukrrs", "beta", "main", "main")
|
||||||
|
srv.AddRepo("other", "gamma", "main", "main")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
owner string
|
||||||
|
want []string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "self", owner: "", want: []string{"ukrrs/alpha", "ukrrs/beta"}, path: "/api/v1/user/repos"},
|
||||||
|
{name: "other user", owner: "other", want: []string{"other/gamma"}, path: "/api/v1/users/other/repos"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
repos, err := c.ListRepos(context.Background(), tt.owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRepos: %v", err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, r := range repos {
|
||||||
|
got = append(got, r.FullName)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
if reqs := srv.Requests(); reqs[len(reqs)-1].Path != tt.path {
|
||||||
|
t.Fatalf("path = %s, want %s", reqs[len(reqs)-1].Path, tt.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRepoMissing(t *testing.T) {
|
||||||
|
c, _ := newClient(t)
|
||||||
|
_, err := c.GetRepo(context.Background(), "ukrrs", "absent")
|
||||||
|
if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
|
|
||||||
// Keys understood in the environment and the env file.
|
// Keys understood in the environment and the env file.
|
||||||
const (
|
const (
|
||||||
KeyURL = "GITEA_URL"
|
KeyURL = "GITEA_URL"
|
||||||
KeyKey = "GITEA_KEY"
|
KeyKey = "GITEA_KEY"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the resolved connection set.
|
// Config is the resolved connection set.
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
// Package fakegitea is a stateful in-memory Gitea API fake used by the
|
||||||
|
// test suite and the smoke run. The real forge is NEVER contacted. It
|
||||||
|
// implements exactly the endpoints the gitea client uses, enforces
|
||||||
|
// "Authorization: token <key>" auth on every one of them, and records
|
||||||
|
// every request so tests can assert round-trips. Error responses
|
||||||
|
// deliberately echo the presented token back in the body: any test that
|
||||||
|
// survives that proves the client never surfaces response bodies
|
||||||
|
// (token-redaction guarantee).
|
||||||
|
package fakegitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one recorded exchange (auth checked, body captured).
|
||||||
|
type Request struct {
|
||||||
|
Method string
|
||||||
|
Path string // path without query
|
||||||
|
Query string // raw query
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is the account shape.
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo is the stored repository (server side).
|
||||||
|
type Repo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Owner User `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch is the branch shape (Commit carries the tip sha).
|
||||||
|
type Branch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Commit CommitInfo `json:"commit"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitInfo is the commit projection branches carry.
|
||||||
|
type CommitInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusRecord is one stored commit status (append-only per sha).
|
||||||
|
type StatusRecord struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Context string `json:"context"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest is the stored PR.
|
||||||
|
type PullRequest struct {
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
State string `json:"state"` // open|closed
|
||||||
|
User User `json:"user"`
|
||||||
|
Base PRBranch `json:"base"`
|
||||||
|
Head PRBranch `json:"head"`
|
||||||
|
Mergeable bool `json:"mergeable"`
|
||||||
|
Merged bool `json:"merged"`
|
||||||
|
MergedAt string `json:"merged_at"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRBranch is one end of a pull request.
|
||||||
|
type PRBranch struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Repo Repo `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoState struct {
|
||||||
|
repo Repo
|
||||||
|
branches map[string]string // name -> tip sha
|
||||||
|
order []string // insertion order of branch names
|
||||||
|
statuses map[string][]StatusRecord
|
||||||
|
prs map[int64]*PullRequest
|
||||||
|
nextPR int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the fake Gitea.
|
||||||
|
type Server struct {
|
||||||
|
URL string
|
||||||
|
Login string // the account the token authenticates as
|
||||||
|
Token string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
srv *httptest.Server
|
||||||
|
requests []Request
|
||||||
|
repos map[string]*repoState // "owner/name" -> state
|
||||||
|
nextRepo int64
|
||||||
|
nextStat int64
|
||||||
|
// Fail, when non-nil, is returned instead of normal handling; it is
|
||||||
|
// consumed by the first request that sees it.
|
||||||
|
Fail *FailSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailSpec pins the next response (error-mapping tests).
|
||||||
|
type FailSpec struct {
|
||||||
|
Status int
|
||||||
|
Body string // may contain one %s: the presented token
|
||||||
|
}
|
||||||
|
|
||||||
|
// New starts a fake on a random port authenticating as login.
|
||||||
|
func New(token, login string) *Server {
|
||||||
|
s := newServer(token, login)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", s.handler)
|
||||||
|
s.srv = httptest.NewServer(mux)
|
||||||
|
s.URL = s.srv.URL
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe runs the fake on a fixed address (the smoke run boots it
|
||||||
|
// in a container). An empty token disables auth checking.
|
||||||
|
func ListenAndServe(addr, token string) error {
|
||||||
|
s := newServer(token, "ukrrs")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", s.handler)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(token, login string) *Server {
|
||||||
|
return &Server{
|
||||||
|
Token: token,
|
||||||
|
Login: login,
|
||||||
|
repos: map[string]*repoState{},
|
||||||
|
nextRepo: 1000,
|
||||||
|
nextStat: 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts the fake down.
|
||||||
|
func (s *Server) Close() { s.srv.Close() }
|
||||||
|
|
||||||
|
// Requests returns a copy of the recorded exchanges.
|
||||||
|
func (s *Server) Requests() []Request {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]Request, len(s.requests))
|
||||||
|
copy(out, s.requests)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRepo seeds a repository directly (bypassing REST) with the given
|
||||||
|
// branches (first is the default when defaultBranch is empty) and returns
|
||||||
|
// its "owner/name" key.
|
||||||
|
func (s *Server) AddRepo(owner, name, defaultBranch string, branches ...string) string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.addRepoLocked(owner, name, defaultBranch, branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) addRepoLocked(owner, name, defaultBranch string, branches []string) string {
|
||||||
|
if defaultBranch == "" && len(branches) > 0 {
|
||||||
|
defaultBranch = branches[0]
|
||||||
|
}
|
||||||
|
s.nextRepo++
|
||||||
|
st := &repoState{
|
||||||
|
repo: Repo{
|
||||||
|
ID: s.nextRepo,
|
||||||
|
Owner: User{ID: 10, Login: owner, FullName: owner},
|
||||||
|
Name: name,
|
||||||
|
FullName: owner + "/" + name,
|
||||||
|
DefaultBranch: defaultBranch,
|
||||||
|
HTMLURL: "https://git.example/" + owner + "/" + name,
|
||||||
|
CloneURL: "https://git.example/" + owner + "/" + name + ".git",
|
||||||
|
},
|
||||||
|
branches: map[string]string{},
|
||||||
|
statuses: map[string][]StatusRecord{},
|
||||||
|
prs: map[int64]*PullRequest{},
|
||||||
|
}
|
||||||
|
for _, b := range branches {
|
||||||
|
st.addBranch(b)
|
||||||
|
}
|
||||||
|
key := owner + "/" + name
|
||||||
|
s.repos[key] = st
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *repoState) addBranch(name string) string {
|
||||||
|
// Deterministic distinct 40-hex shas (fake but well-formed).
|
||||||
|
sha := fmt.Sprintf("%040x", len(st.order)+1)
|
||||||
|
if _, ok := st.branches[name]; !ok {
|
||||||
|
st.order = append(st.order, name)
|
||||||
|
}
|
||||||
|
st.branches[name] = sha
|
||||||
|
return sha
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBranch seeds a branch directly and returns its (new) tip sha.
|
||||||
|
func (s *Server) AddBranch(owner, name, branch string) string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.repos[owner+"/"+name]
|
||||||
|
return st.addBranch(branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo returns a copy of a stored repo (for assertions).
|
||||||
|
func (s *Server) Repo(owner, name string) (Repo, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return Repo{}, false
|
||||||
|
}
|
||||||
|
return st.repo, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest returns a copy of a stored PR (for assertions).
|
||||||
|
func (s *Server) PullRequest(owner, name string, number int64) (PullRequest, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return PullRequest{}, false
|
||||||
|
}
|
||||||
|
pr, ok := st.prs[number]
|
||||||
|
if !ok {
|
||||||
|
return PullRequest{}, false
|
||||||
|
}
|
||||||
|
return *pr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statuses returns the raw status history of one sha (for assertions).
|
||||||
|
func (s *Server) Statuses(owner, name, sha string) []StatusRecord {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]StatusRecord, len(st.statuses[sha]))
|
||||||
|
copy(out, st.statuses[sha])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := ""
|
||||||
|
if s.Token != "" {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "token ") {
|
||||||
|
token = strings.TrimPrefix(auth, "token ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.Fail != nil {
|
||||||
|
spec := *s.Fail
|
||||||
|
s.Fail = nil
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
||||||
|
s.mu.Unlock()
|
||||||
|
// The body deliberately embeds the presented token: surviving
|
||||||
|
// this proves the client drops response bodies from errors.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(spec.Status)
|
||||||
|
fmt.Fprintf(w, spec.Body, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Token != "" && token != s.Token {
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""})
|
||||||
|
s.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprintf(w, `{"message":"bad credentials: token %s was rejected"}`, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
||||||
|
|
||||||
|
var (
|
||||||
|
respStatus = http.StatusOK
|
||||||
|
respBody string
|
||||||
|
)
|
||||||
|
path := r.URL.Path
|
||||||
|
q := r.URL.Query()
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && path == "/api/v1/user/repos":
|
||||||
|
respStatus, respBody = s.listRepos(s.Login)
|
||||||
|
case r.Method == http.MethodPost && path == "/api/v1/user/repos":
|
||||||
|
respStatus, respBody = s.createRepo(s.Login, body)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/users/") && strings.HasSuffix(path, "/repos"):
|
||||||
|
owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/users/"), "/repos")
|
||||||
|
respStatus, respBody = s.listRepos(owner)
|
||||||
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos"):
|
||||||
|
owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/orgs/"), "/repos")
|
||||||
|
respStatus, respBody = s.createRepo(owner, body)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/repos/") && strings.Count(path, "/") == 5:
|
||||||
|
owner, name := splitRepo(strings.TrimPrefix(path, "/api/v1/repos/"))
|
||||||
|
respStatus, respBody = s.getRepo(owner, name)
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(path, "/branches") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/branches"))
|
||||||
|
respStatus, respBody = s.listBranches(owner, name)
|
||||||
|
case r.Method == http.MethodPost && strings.Contains(path, "/statuses/") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
rest := strings.TrimPrefix(path, "/api/v1/repos/")
|
||||||
|
parts := strings.SplitN(rest, "/statuses/", 2)
|
||||||
|
owner, name := splitRepo(parts[0])
|
||||||
|
respStatus, respBody = s.createStatus(owner, name, parts[1], body)
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(path, "/statuses") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/statuses")
|
||||||
|
parts := strings.SplitN(rest, "/commits/", 2)
|
||||||
|
owner, name := splitRepo(parts[0])
|
||||||
|
respStatus, respBody = s.listStatuses(owner, name, parts[1])
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(path, "/status") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/status")
|
||||||
|
parts := strings.SplitN(rest, "/commits/", 2)
|
||||||
|
owner, name := splitRepo(parts[0])
|
||||||
|
respStatus, respBody = s.combinedStatus(owner, name, parts[1])
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls"))
|
||||||
|
respStatus, respBody = s.listPulls(owner, name, q.Get("state"))
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls"))
|
||||||
|
respStatus, respBody = s.createPull(owner, name, body)
|
||||||
|
case r.Method == http.MethodPost && strings.Contains(path, "/pulls/") && strings.HasSuffix(path, "/merge") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
rest := strings.TrimPrefix(path, "/api/v1/repos/")
|
||||||
|
parts := strings.SplitN(rest, "/pulls/", 2)
|
||||||
|
owner, name := splitRepo(parts[0])
|
||||||
|
num, _ := strconv.ParseInt(strings.TrimSuffix(parts[1], "/merge"), 10, 64)
|
||||||
|
respStatus, respBody = s.mergePull(owner, name, num, body)
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(path, "/pulls/") && strings.HasPrefix(path, "/api/v1/repos/"):
|
||||||
|
rest := strings.TrimPrefix(path, "/api/v1/repos/")
|
||||||
|
parts := strings.SplitN(rest, "/pulls/", 2)
|
||||||
|
owner, name := splitRepo(parts[0])
|
||||||
|
num, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
respStatus, respBody = s.getPull(owner, name, num)
|
||||||
|
default:
|
||||||
|
respStatus, respBody = http.StatusNotFound, `{"message":"Not Found"}`
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(respStatus)
|
||||||
|
if respBody != "" {
|
||||||
|
io.WriteString(w, respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitRepo(s string) (owner, name string) {
|
||||||
|
parts := strings.SplitN(s, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return parts[0], parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonReply(v any) (int, string) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, `{"message":"marshal"}`
|
||||||
|
}
|
||||||
|
return http.StatusOK, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- repos -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) listRepos(owner string) (int, string) {
|
||||||
|
var out []Repo
|
||||||
|
for _, key := range s.sortedRepoKeys() {
|
||||||
|
st := s.repos[key]
|
||||||
|
if st.repo.Owner.Login == owner {
|
||||||
|
out = append(out, st.repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []Repo{}
|
||||||
|
}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sortedRepoKeys() []string {
|
||||||
|
keys := make([]string, 0, len(s.repos))
|
||||||
|
for k := range s.repos {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getRepo(owner, name string) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
return jsonReply(st.repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createRepo(owner string, body []byte) (int, string) {
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
AutoInit bool `json:"auto_init"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"message":"bad json"}`
|
||||||
|
}
|
||||||
|
if p.Name == "" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"message":"name is required"}`
|
||||||
|
}
|
||||||
|
if _, exists := s.repos[owner+"/"+p.Name]; exists {
|
||||||
|
return http.StatusConflict, `{"message":"The repository with the same name already exists."}`
|
||||||
|
}
|
||||||
|
branches := []string(nil)
|
||||||
|
if p.AutoInit {
|
||||||
|
branches = []string{"main"}
|
||||||
|
}
|
||||||
|
key := s.addRepoLocked(owner, p.Name, p.DefaultBranch, branches)
|
||||||
|
st := s.repos[key]
|
||||||
|
st.repo.Description = p.Description
|
||||||
|
st.repo.Private = p.Private
|
||||||
|
return http.StatusCreated, mustJSON(st.repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) string {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- branches ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) listBranches(owner, name string) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
out := make([]Branch, 0, len(st.order))
|
||||||
|
for _, b := range st.order {
|
||||||
|
sha := st.branches[b]
|
||||||
|
out = append(out, Branch{Name: b, Commit: CommitInfo{ID: sha, Message: "fake commit " + sha[:7]}})
|
||||||
|
}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRef maps a ref (full sha, sha prefix, or branch name) to a commit
|
||||||
|
// sha; ok=false when nothing matches.
|
||||||
|
func (st *repoState) resolveRef(ref string) (string, bool) {
|
||||||
|
if sha, ok := st.branches[ref]; ok {
|
||||||
|
return sha, true
|
||||||
|
}
|
||||||
|
for _, sha := range st.branches {
|
||||||
|
if strings.HasPrefix(sha, ref) && len(ref) >= 7 {
|
||||||
|
return sha, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- statuses ---------------------------------------------------------------
|
||||||
|
|
||||||
|
var validStates = map[string]bool{"pending": true, "success": true, "error": true, "failure": true}
|
||||||
|
|
||||||
|
func (s *Server) createStatus(owner, name, sha string, body []byte) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
full, ok := st.resolveRef(sha)
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, fmt.Sprintf(`{"message":"commit %s not found"}`, sha)
|
||||||
|
}
|
||||||
|
var p StatusRecord
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"message":"bad json"}`
|
||||||
|
}
|
||||||
|
if !validStates[p.State] {
|
||||||
|
return http.StatusUnprocessableEntity, `{"message":"invalid state"}`
|
||||||
|
}
|
||||||
|
if p.Context == "" {
|
||||||
|
p.Context = "default"
|
||||||
|
}
|
||||||
|
s.nextStat++
|
||||||
|
p.ID = s.nextStat
|
||||||
|
p.CreatedAt = "2026-08-29T16:00:00Z"
|
||||||
|
p.UpdatedAt = p.CreatedAt
|
||||||
|
st.statuses[full] = append(st.statuses[full], p)
|
||||||
|
return http.StatusCreated, mustJSON(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestPerContext folds the append-only history into the newest status
|
||||||
|
// per context (Gitea's reported shape), in insertion order.
|
||||||
|
func latestPerContext(history []StatusRecord) []StatusRecord {
|
||||||
|
idx := map[string]int{}
|
||||||
|
var out []StatusRecord
|
||||||
|
for _, sr := range history {
|
||||||
|
if i, ok := idx[sr.Context]; ok {
|
||||||
|
out[i] = sr
|
||||||
|
} else {
|
||||||
|
idx[sr.Context] = len(out)
|
||||||
|
out = append(out, sr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listStatuses(owner, name, ref string) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
full, ok := st.resolveRef(ref)
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"commit not found"}`
|
||||||
|
}
|
||||||
|
out := latestPerContext(st.statuses[full])
|
||||||
|
if out == nil {
|
||||||
|
out = []StatusRecord{}
|
||||||
|
}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func worstState(statuses []StatusRecord) string {
|
||||||
|
rank := map[string]int{"success": 0, "pending": 1, "failure": 2, "error": 3}
|
||||||
|
worst, seen := "", false
|
||||||
|
for _, sr := range statuses {
|
||||||
|
if !seen || rank[sr.State] > rank[worst] {
|
||||||
|
worst, seen = sr.State, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) combinedStatus(owner, name, ref string) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
full, ok := st.resolveRef(ref)
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"commit not found"}`
|
||||||
|
}
|
||||||
|
latest := latestPerContext(st.statuses[full])
|
||||||
|
if len(latest) == 0 {
|
||||||
|
return http.StatusNotFound, `{"message":"no statuses for this commit"}`
|
||||||
|
}
|
||||||
|
out := struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
Statuses []StatusRecord `json:"statuses"`
|
||||||
|
}{worstState(latest), full, len(latest), latest}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pull requests ----------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) prBranch(st *repoState, ref string) (PRBranch, bool) {
|
||||||
|
branch := ref
|
||||||
|
if i := strings.IndexByte(ref, ':'); i >= 0 { // fork syntax owner:branch
|
||||||
|
branch = ref[i+1:]
|
||||||
|
}
|
||||||
|
sha, ok := st.branches[branch]
|
||||||
|
if !ok {
|
||||||
|
return PRBranch{}, false
|
||||||
|
}
|
||||||
|
return PRBranch{Ref: branch, SHA: sha, Repo: st.repo}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createPull(owner, name string, body []byte) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"message":"bad json"}`
|
||||||
|
}
|
||||||
|
if p.Title == "" {
|
||||||
|
return http.StatusUnprocessableEntity, `{"message":"title is required"}`
|
||||||
|
}
|
||||||
|
base, ok := s.prBranch(st, p.Base)
|
||||||
|
if !ok {
|
||||||
|
return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"base branch %s does not exist"}`, p.Base)
|
||||||
|
}
|
||||||
|
head, ok := s.prBranch(st, p.Head)
|
||||||
|
if !ok {
|
||||||
|
return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"head branch %s does not exist"}`, p.Head)
|
||||||
|
}
|
||||||
|
if base.Ref == head.Ref {
|
||||||
|
return http.StatusUnprocessableEntity, `{"message":"head and base cannot be the same"}`
|
||||||
|
}
|
||||||
|
for _, pr := range st.prs {
|
||||||
|
if pr.State == "open" && pr.Base.Ref == base.Ref && pr.Head.Ref == head.Ref {
|
||||||
|
return http.StatusConflict, `{"message":"The pull request has already been created."}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.nextPR++
|
||||||
|
now := "2026-08-29T16:00:00Z"
|
||||||
|
pr := &PullRequest{
|
||||||
|
Number: st.nextPR,
|
||||||
|
Title: p.Title,
|
||||||
|
Body: p.Body,
|
||||||
|
State: "open",
|
||||||
|
User: User{ID: 10, Login: s.Login},
|
||||||
|
Base: base,
|
||||||
|
Head: head,
|
||||||
|
Mergeable: true,
|
||||||
|
HTMLURL: st.repo.HTMLURL + "/pulls/" + strconv.FormatInt(st.nextPR, 10),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
st.prs[pr.Number] = pr
|
||||||
|
return http.StatusCreated, mustJSON(pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listPulls(owner, name, state string) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
state = "open" // Gitea default
|
||||||
|
}
|
||||||
|
var nums []int64
|
||||||
|
for n := range st.prs {
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] })
|
||||||
|
out := []PullRequest{}
|
||||||
|
for _, n := range nums {
|
||||||
|
pr := st.prs[n]
|
||||||
|
if state == "all" || pr.State == state {
|
||||||
|
out = append(out, *pr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonReply(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getPull(owner, name string, num int64) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
pr, ok := st.prs[num]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The pull request couldn't be found."}`
|
||||||
|
}
|
||||||
|
return jsonReply(pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mergeStyles = map[string]bool{"merge": true, "rebase": true, "rebase-merge": true, "squash": true, "fast-forward": true}
|
||||||
|
|
||||||
|
func (s *Server) mergePull(owner, name string, num int64, body []byte) (int, string) {
|
||||||
|
st, ok := s.repos[owner+"/"+name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
|
||||||
|
}
|
||||||
|
pr, ok := st.prs[num]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, `{"message":"The pull request couldn't be found."}`
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Do string `json:"Do"`
|
||||||
|
Title string `json:"MergeTitleField"`
|
||||||
|
Message string `json:"MergeMessageField"`
|
||||||
|
}
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return http.StatusBadRequest, `{"message":"bad json"}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Do == "" {
|
||||||
|
p.Do = "merge"
|
||||||
|
}
|
||||||
|
if !mergeStyles[p.Do] {
|
||||||
|
return http.StatusUnprocessableEntity, `{"message":"invalid merge style"}`
|
||||||
|
}
|
||||||
|
if pr.State != "open" || pr.Merged {
|
||||||
|
return http.StatusConflict, `{"message":"The pull request has already been merged or closed."}`
|
||||||
|
}
|
||||||
|
pr.Merged = true
|
||||||
|
pr.State = "closed"
|
||||||
|
pr.Mergeable = false
|
||||||
|
pr.MergedAt = "2026-08-29T16:05:00Z"
|
||||||
|
pr.UpdatedAt = pr.MergedAt
|
||||||
|
// The head lands on base: base now points at the head sha.
|
||||||
|
st.branches[pr.Base.Ref] = pr.Head.SHA
|
||||||
|
return http.StatusOK, `{"message":"pull request has been merged"}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user