Files
amacocian a0071f147b
CI / build (push) Failing after 1s
Add support for completions
2026-06-13 12:58:11 +02:00

124 lines
3.7 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// ErrNotFound is returned wrapped when Gitea answers 404. Lets tool
// handlers special-case "this resource just doesn't exist" (e.g. an
// uninitialised wiki) and return an empty result instead of bubbling
// up a confusing HTTP error.
var ErrNotFound = errors.New("gitea: not found")
// giteaAPI is a thin wrapper around the few Gitea REST endpoints we
// use. We don't pull in code.gitea.io/sdk/gitea because its client
// only knows how to send `Authorization: token <PAT>`, not the
// `Authorization: Bearer <OAuth>` that Gitea's OAuth2 server issues.
//
// token is a context-aware getter: it triggers sherlock's lazy OAuth
// flow on first use, then returns the current (background-renewed)
// access token. Threading ctx lets a first-call browser flow be
// cancelled with the request, and the error surfaces auth failures to
// the tool caller.
type giteaAPI struct {
baseURL string
token func(context.Context) (string, error)
client *http.Client
}
// get does a GET against path (with optional query) and JSON-decodes
// into into.
func (g *giteaAPI) get(ctx context.Context, path string, query url.Values, into any) error {
full := g.baseURL + path
if len(query) > 0 {
full += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return err
}
tok, err := g.token(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
dbg("GET %s", full)
resp, err := g.client.Do(req)
if err != nil {
dbg("GET %s -> transport error: %v", full, err)
return err
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
dbg("GET %s -> %v", full, err)
return err
}
dbg("GET %s -> %d", full, resp.StatusCode)
if into == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(into)
}
// getRaw returns the raw body (up to maxBytes; anything beyond is
// silently dropped). Useful for endpoints that return plain text or
// binary (e.g. job logs).
func (g *giteaAPI) getRaw(ctx context.Context, path string, query url.Values, maxBytes int64) ([]byte, bool, error) {
full := g.baseURL + path
if len(query) > 0 {
full += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, false, err
}
tok, err := g.token(ctx)
if err != nil {
return nil, false, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "*/*")
resp, err := g.client.Do(req)
if err != nil {
return nil, false, err
}
defer func() { _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
return nil, false, err
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, false, err
}
truncated := false
if int64(len(body)) > maxBytes {
body = body[:maxBytes]
truncated = true
}
return body, truncated, nil
}
func checkStatus(resp *http.Response, req *http.Request) error {
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("gitea: 401 (token rejected by %s; try `sherlock logout gitea` and retry)", req.URL.Host)
}
if resp.StatusCode == http.StatusNotFound {
// Wrap so callers can errors.Is(err, ErrNotFound).
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%w: %s %s: %s", ErrNotFound, req.Method, req.URL.Path, strings.TrimSpace(string(body)))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("gitea: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}