Synchronized with latest upstream changes, including style fixes and
CI hardening. Resolved a conflict in operation/issue/issue.go by
maintaining the context-aware client pattern.
316 lines
9.6 KiB
Go
316 lines
9.6 KiB
Go
package forgejo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
|
|
)
|
|
|
|
// MaxInlineDownloadBytes caps inline base64 attachment payloads.
|
|
// Files at or above this size return metadata only; the caller is expected
|
|
// to fetch browser_download_url directly with the same auth token.
|
|
// See docs/plans/issue-attachments.md and Codeberg issue #106.
|
|
const MaxInlineDownloadBytes = 1 * 1024 * 1024
|
|
|
|
// Error sentinels callers can match with errors.Is.
|
|
var (
|
|
ErrUnauthorized = errors.New("unauthorized")
|
|
ErrNotFound = errors.New("not found")
|
|
ErrPayloadTooLarge = errors.New("payload exceeds inline cap")
|
|
)
|
|
|
|
// HTTPError carries the response status and a sanitised body snippet.
|
|
// It wraps one of the sentinels above when the status maps to one.
|
|
type HTTPError struct {
|
|
StatusCode int
|
|
Status string
|
|
Body string
|
|
Method string
|
|
URL string
|
|
wrapped error
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf("%s %s: %s: %s", e.Method, e.URL, e.Status, e.Body)
|
|
}
|
|
|
|
func (e *HTTPError) Unwrap() error { return e.wrapped }
|
|
|
|
// rawHTTPClient is package-level so tests can swap timeouts; a single
|
|
// shared client lets keep-alives work across tool calls.
|
|
var rawHTTPClient = &http.Client{Timeout: 60 * time.Second}
|
|
|
|
// userAgent returns the configured UA, falling back to forgejo-mcp/<version>.
|
|
func userAgent() string {
|
|
if flag.UserAgent != "" {
|
|
return flag.UserAgent
|
|
}
|
|
return "forgejo-mcp/" + flag.Version
|
|
}
|
|
|
|
// resolveURL turns a path or absolute URL into an absolute URL string.
|
|
// API paths (e.g. "/repos/x/y/issues/1/assets") are prefixed with
|
|
// flag.URL + "/api/v1". Absolute URLs are returned verbatim.
|
|
func resolveURL(pathOrURL string) (string, error) {
|
|
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
|
return pathOrURL, nil
|
|
}
|
|
base := strings.TrimRight(flag.URL, "/")
|
|
if base == "" {
|
|
return "", fmt.Errorf("flag.URL is empty; raw-HTTP helper needs a configured base URL")
|
|
}
|
|
if !strings.HasPrefix(pathOrURL, "/") {
|
|
pathOrURL = "/" + pathOrURL
|
|
}
|
|
// Forgejo REST API root.
|
|
return base + "/api/v1" + pathOrURL, nil
|
|
}
|
|
|
|
// resolveSameOriginURL is like resolveURL but for asset/download URLs that
|
|
// live outside the /api/v1 prefix (e.g. /attachments/{uuid}). Absolute URLs
|
|
// pass through; relative URLs hang off flag.URL with no /api/v1.
|
|
func resolveSameOriginURL(pathOrURL string) (string, error) {
|
|
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
|
return pathOrURL, nil
|
|
}
|
|
base := strings.TrimRight(flag.URL, "/")
|
|
if base == "" {
|
|
return "", fmt.Errorf("flag.URL is empty; raw-HTTP helper needs a configured base URL")
|
|
}
|
|
if !strings.HasPrefix(pathOrURL, "/") {
|
|
pathOrURL = "/" + pathOrURL
|
|
}
|
|
return base + pathOrURL, nil
|
|
}
|
|
|
|
func setCommonHeaders(ctx context.Context, req *http.Request) {
|
|
token, ok := ctx.Value(TokenContextKey).(string)
|
|
if !ok || token == "" {
|
|
token = flag.Token
|
|
}
|
|
req.Header.Set("Authorization", "token "+token)
|
|
req.Header.Set("User-Agent", userAgent())
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|
|
|
|
// doRequest sends req, returns the response, mapping common HTTP errors to
|
|
// the sentinels above. Caller owns response body close.
|
|
func doRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
|
start := time.Now()
|
|
resp, err := rawHTTPClient.Do(req)
|
|
duration := time.Since(start)
|
|
endpoint := req.URL.Path
|
|
if req.URL.RawQuery != "" {
|
|
endpoint += "?" + req.URL.RawQuery
|
|
}
|
|
if err != nil {
|
|
LogAPICall(ctx, req.Method, endpoint, duration, 0, err)
|
|
return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL.String(), err)
|
|
}
|
|
LogAPICall(ctx, req.Method, endpoint, duration, resp.StatusCode, nil)
|
|
return resp, nil
|
|
}
|
|
|
|
// readBodySnippet reads up to 1 KiB of the body for inclusion in errors.
|
|
// It does not close the body.
|
|
func readBodySnippet(r io.Reader) string {
|
|
buf := make([]byte, 1024)
|
|
n, _ := io.ReadFull(io.LimitReader(r, 1024), buf)
|
|
return string(buf[:n])
|
|
}
|
|
|
|
// httpErrorFromResponse builds an HTTPError mapping status to a sentinel.
|
|
func httpErrorFromResponse(req *http.Request, resp *http.Response) *HTTPError {
|
|
body := readBodySnippet(resp.Body)
|
|
e := &HTTPError{
|
|
StatusCode: resp.StatusCode,
|
|
Status: resp.Status,
|
|
Body: body,
|
|
Method: req.Method,
|
|
URL: req.URL.String(),
|
|
}
|
|
switch {
|
|
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
|
e.wrapped = ErrUnauthorized
|
|
case resp.StatusCode == http.StatusNotFound:
|
|
e.wrapped = ErrNotFound
|
|
}
|
|
return e
|
|
}
|
|
|
|
// DoJSON performs an authenticated JSON request. Encodes body as JSON if
|
|
// non-nil; decodes 2xx response into out if non-nil. 204 responses are
|
|
// always success-with-no-body. 4xx/5xx return *HTTPError.
|
|
//
|
|
// The boolean isList signals "list endpoint": for those, 404 is treated
|
|
// as an empty list (not an error), matching Forgejo's habit of 404ing
|
|
// list endpoints when the parent entity has no children.
|
|
func DoJSON(ctx context.Context, method, pathOrURL string, body, out any) error {
|
|
full, err := resolveURL(pathOrURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, full, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("build request: %w", err)
|
|
}
|
|
setCommonHeaders(ctx, req)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := doRequest(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return httpErrorFromResponse(req, resp)
|
|
}
|
|
if resp.StatusCode == http.StatusNoContent || out == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DoJSONList is like DoJSON but treats 404 as "empty list" (no error,
|
|
// out left at its zero value).
|
|
func DoJSONList(ctx context.Context, method, pathOrURL string, out any) error {
|
|
err := DoJSON(ctx, method, pathOrURL, nil, out)
|
|
var he *HTTPError
|
|
if errors.As(err, &he) && he.StatusCode == http.StatusNotFound {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// quoteEscaper mirrors mime/multipart's internal escaper for filenames.
|
|
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
|
|
|
|
// DoMultipart uploads a single file part via multipart/form-data and
|
|
// decodes the JSON response into out (if non-nil).
|
|
func DoMultipart(ctx context.Context, method, pathOrURL, fieldName, filename, mimeType string, r io.Reader, out any) error {
|
|
full, err := resolveURL(pathOrURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(body)
|
|
|
|
h := textproto.MIMEHeader{}
|
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
|
|
quoteEscaper.Replace(fieldName), quoteEscaper.Replace(filename)))
|
|
if mimeType != "" {
|
|
h.Set("Content-Type", mimeType)
|
|
} else {
|
|
h.Set("Content-Type", "application/octet-stream")
|
|
}
|
|
part, err := mw.CreatePart(h)
|
|
if err != nil {
|
|
return fmt.Errorf("create multipart part: %w", err)
|
|
}
|
|
if _, err := io.Copy(part, r); err != nil {
|
|
return fmt.Errorf("copy file into part: %w", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, full, body)
|
|
if err != nil {
|
|
return fmt.Errorf("build request: %w", err)
|
|
}
|
|
setCommonHeaders(ctx, req)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
resp, err := doRequest(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return httpErrorFromResponse(req, resp)
|
|
}
|
|
if resp.StatusCode == http.StatusNoContent || out == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DoRaw fetches bytes from a URL (absolute or relative-to-flag.URL with no
|
|
// /api/v1 prefix), adding the configured auth header. Caps the response at
|
|
// MaxInlineDownloadBytes; ErrPayloadTooLarge is returned if the body would
|
|
// exceed the cap. Returns body bytes + content type.
|
|
func DoRaw(ctx context.Context, pathOrURL string) ([]byte, string, error) {
|
|
full, err := resolveSameOriginURL(pathOrURL)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
setCommonHeaders(ctx, req)
|
|
// Don't constrain Accept here — the asset endpoint is binary.
|
|
|
|
resp, err := doRequest(ctx, req)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, "", httpErrorFromResponse(req, resp)
|
|
}
|
|
|
|
// Read up to cap+1 to detect overflow.
|
|
limited := io.LimitReader(resp.Body, MaxInlineDownloadBytes+1)
|
|
buf, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read body: %w", err)
|
|
}
|
|
if int64(len(buf)) > MaxInlineDownloadBytes {
|
|
return nil, "", ErrPayloadTooLarge
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
return buf, ct, nil
|
|
}
|
|
|
|
// helper used by tests to validate URL construction directly.
|
|
func init() {
|
|
// Validate at init that net/url accepts our base format if set.
|
|
if flag.URL != "" {
|
|
if _, err := url.Parse(flag.URL); err != nil {
|
|
log.Errorf("flag.URL is not a valid URL: %v", err)
|
|
}
|
|
}
|
|
}
|