- 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
81 lines
2.6 KiB
Go
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)))
|
|
}
|