Files
projax/gitea/client.go
mAi 5a56ad91e5 feat(phase 3h gitea writeback): close/reopen/comment/create from projax
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
  classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
  - authorisation guard: repo form value must match a gitea-repo item_link
    on the target item (rejects form-crafted writes to unrelated repos)
  - HTMX re-renders issues_section partial after each action
  - busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
  issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
  semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
  + 403 → inline banner fallback
2026-05-15 19:22:11 +02:00

81 lines
2.6 KiB
Go

// Package gitea is a minimal client for the slice of Gitea that projax needs:
// list open + recently-closed issues on a repo. Read-only at v1 — writeback
// is parked until phase 2.e.
package gitea
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client wraps a base URL + automation-account token.
type Client struct {
BaseURL string // e.g. https://mgit.msbls.de
Token string
HTTPClient *http.Client
}
// New builds a Client with a sensible default timeout. Token is the
// automation-account API token (Authorization: token <…>). base must NOT
// include a trailing /api/v1 — the client adds the API prefix itself.
func New(base, token string) *Client {
base = strings.TrimRight(base, "/")
return &Client{
BaseURL: base,
Token: token,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
// do issues a single request, attaches token auth, and returns the raw
// response for the caller to decode.
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
u := c.BaseURL + "/api/v1" + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body))
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "token "+c.Token)
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
return c.HTTPClient.Do(req)
}
// ErrForbidden surfaces 403 / 401 responses so writeback callers can render a
// distinct "token lacks permission" banner instead of a generic upstream error.
// 401 typically means the token is missing or invalid; 403 means the token is
// valid but the user lacks write on this repo.
var ErrForbidden = errors.New("gitea: forbidden (token missing or lacks write access)")
// ErrNotFound is returned when the Gitea API responds 404 for a repo or
// resource. Most often this surfaces when the linked owner/repo no longer
// exists (renamed, archived, deleted).
var ErrNotFound = errors.New("gitea: not found")
// readErr collapses a non-2xx Gitea response into a single error containing
// the status code and (trimmed) body so the caller can log it.
func readErr(resp *http.Response, op string) error {
raw, _ := io.ReadAll(resp.Body)
switch resp.StatusCode {
case http.StatusNotFound:
return ErrNotFound
case http.StatusUnauthorized, http.StatusForbidden:
return ErrForbidden
}
return fmt.Errorf("gitea %s: %d %s", op, resp.StatusCode, strings.TrimSpace(string(raw)))
}