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
This commit is contained in:
@@ -256,16 +256,24 @@ m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth
|
||||
|
||||
Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section.
|
||||
|
||||
## 6. Gitea integration (Phase 2.d, v1: read-only)
|
||||
## 6. Gitea integration (Phase 2.d read; 3h writeback)
|
||||
|
||||
m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). projax v1 reads but does not write:
|
||||
m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). Phase 2.d landed read-only; Phase 3h extended it to read + write for the four most common operations:
|
||||
|
||||
- **Link model**: a `projax.item_links` row with `ref_type='gitea-repo'`, `ref_id='<owner>/<repo>'` (e.g. `m/projax`, `mAi/paliad`, `HL/mWorkRepo`). The Phase 1.5 backfill already populated this row for every `mai.projects` with a `repo` field. An item can carry multiple `gitea-repo` links — projax sums them on the detail page.
|
||||
- **Issues section** (item detail page, rendered when at least one `gitea-repo` link exists): per-repo block with open issues (`#N · title · labels · milestone · assignees · updated <rel>`), a `↗ Gitea repo` link in the header, and a disclosure for the last-30-days closed issues (up to 20). Title and number link out to `htmlURL` on Gitea (`target="_blank"`). Failed fetches (404, network) surface as a per-repo banner so one missing repo doesn't blank the section.
|
||||
- **Listing**: `GET /api/v1/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50` for the open list; same shape with `state=closed&since=<-30d>&limit=20` for the recent-closed disclosure. `type=issues` filters PRs out server-side on Gitea ≥1.20; the client also drops any `pull_request != null` rawIssue as belt-and-braces.
|
||||
- **Caching**: per-process, in-memory TTL cache (~3 min) keyed by `owner/repo|state` so rendering the same detail page back-to-back does not hammer Gitea. No DB cache table at v1; a `projax.cached_issues` would land in 2.f if perf bites.
|
||||
- **Auth**: `Authorization: token <GITEA_TOKEN>`. The token is the **mAi** automation account (`GITEA_TOKEN_AI` in `.env.age`) — keeps projax's reads attributed to mAi for audit purposes, same as how every other automated worker talks to Gitea. Missing token + non-empty URL → fail-fast at boot.
|
||||
- **PR aggregation, issue writeback, webhook live updates**: parked. Writeback is Phase 2.e if m wants it; webhook-driven freshness is 2.f.
|
||||
- **Writeback (Phase 3h)** — four operations on the Issues section + dashboard Issues card:
|
||||
- **Close** an open issue (`PATCH /repos/{o}/{r}/issues/{n}` with `{"state":"closed"}`) — single click, no confirm modal (cheap to reopen).
|
||||
- **Reopen** a closed issue (same endpoint with `{"state":"open"}`).
|
||||
- **Comment** on an issue (`POST /repos/{o}/{r}/issues/{n}/comments` with `{"body":...}`).
|
||||
- **Create** a new issue under a linked repo (`POST /repos/{o}/{r}/issues` with `{"title":..., "body":...}`).
|
||||
- **Authorisation**: writeback handlers reject any `repo` form value that isn't linked to the item via a `gitea-repo` item_link. Prevents form-crafted writes against arbitrary repos.
|
||||
- **Token permission**: the mAi token (`GITEA_TOKEN_AI`) needs write scope on m's repos. A 401/403 surfaces as `gitea.ErrForbidden` and renders an inline "Gitea token lacks write access" banner so the page never breaks.
|
||||
- **Cache busting**: every successful writeback invalidates both the Gitea per-repo cache entries (`{repo}|open` + `{repo}|closed-recent`) and the dashboard 60s TTL (all keys) so the next render reflects the upstream change.
|
||||
- **Parked further**: PR creation, label edit (folded in only if cheap), issue title/body edit, comment edit/delete, webhook live updates, cross-repo bulk ops, issue templates.
|
||||
|
||||
Env contract: `GITEA_URL` (e.g. `https://mgit.msbls.de`, no `/api/v1` suffix), `GITEA_TOKEN`. Both live in Dokploy secrets; `GITEA_URL` unset → integration off cleanly (Issues section just doesn't render). `GITEA_URL` set but `GITEA_TOKEN` missing → refuse to start.
|
||||
|
||||
|
||||
@@ -49,9 +49,18 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
||||
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).
|
||||
@@ -61,8 +70,11 @@ var ErrNotFound = errors.New("gitea: not found")
|
||||
// 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)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
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)))
|
||||
}
|
||||
|
||||
98
gitea/writeback.go
Normal file
98
gitea/writeback.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Comment mirrors the slice of POST .../comments response that projax needs
|
||||
// for round-trip display. Body + URL are enough; comment edits are out of
|
||||
// scope at v1.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CloseIssue sets state=closed on an open issue. Idempotent — closing an
|
||||
// already-closed issue is a no-op upstream (Gitea returns 201 with the
|
||||
// current state echoed back).
|
||||
func (c *Client) CloseIssue(ctx context.Context, owner, repo string, number int) error {
|
||||
return c.patchIssueState(ctx, owner, repo, number, "closed")
|
||||
}
|
||||
|
||||
// ReopenIssue sets state=open. Same idempotency notes as CloseIssue.
|
||||
func (c *Client) ReopenIssue(ctx context.Context, owner, repo string, number int) error {
|
||||
return c.patchIssueState(ctx, owner, repo, number, "open")
|
||||
}
|
||||
|
||||
func (c *Client) patchIssueState(ctx context.Context, owner, repo string, number int, state string) error {
|
||||
body, _ := json.Marshal(map[string]string{"state": state})
|
||||
resp, err := c.do(ctx, "PATCH", "/repos/"+owner+"/"+repo+"/issues/"+strconv.Itoa(number), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return readErr(resp, fmt.Sprintf("patch issue state=%s", state))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateIssue files a new issue. Returns the upstream Issue shape so the UI
|
||||
// can prepend it to the list without a refetch.
|
||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body string) (*Issue, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"title": title, "body": body})
|
||||
resp, err := c.do(ctx, "POST", "/repos/"+owner+"/"+repo+"/issues", nil, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 200 {
|
||||
return nil, readErr(resp, "create issue")
|
||||
}
|
||||
var r rawIssue
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := Issue{
|
||||
Number: r.Number,
|
||||
Title: r.Title,
|
||||
State: r.State,
|
||||
HTMLURL: r.HTMLURL,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
ClosedAt: r.ClosedAt,
|
||||
}
|
||||
for _, l := range r.Labels {
|
||||
out.Labels = append(out.Labels, l.Name)
|
||||
}
|
||||
for _, a := range r.Assignees {
|
||||
out.Assignees = append(out.Assignees, a.Login)
|
||||
}
|
||||
if r.Milestone != nil {
|
||||
out.Milestone = r.Milestone.Title
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AddComment posts a comment on an issue and returns the created comment.
|
||||
func (c *Client) AddComment(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"body": body})
|
||||
resp, err := c.do(ctx, "POST", "/repos/"+owner+"/"+repo+"/issues/"+strconv.Itoa(number)+"/comments", nil, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 200 {
|
||||
return nil, readErr(resp, "add comment")
|
||||
}
|
||||
var out Comment
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
141
gitea/writeback_test.go
Normal file
141
gitea/writeback_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCloseIssueRoundTrip(t *testing.T) {
|
||||
var gotState string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
t.Errorf("method = %s, want PATCH", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %q", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotState = body["state"]
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
if err := c.CloseIssue(context.Background(), "m", "projax", 42); err != nil {
|
||||
t.Fatalf("CloseIssue: %v", err)
|
||||
}
|
||||
if gotState != "closed" {
|
||||
t.Errorf("upstream got state=%q, want 'closed'", gotState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenIssueRoundTrip(t *testing.T) {
|
||||
var gotState string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotState = body["state"]
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
if err := c.ReopenIssue(context.Background(), "m", "projax", 42); err != nil {
|
||||
t.Fatalf("ReopenIssue: %v", err)
|
||||
}
|
||||
if gotState != "open" {
|
||||
t.Errorf("upstream got state=%q, want 'open'", gotState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueRoundTrip(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/issues") {
|
||||
t.Errorf("path = %q, want suffix /issues", r.URL.Path)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["title"] != "Test issue" {
|
||||
t.Errorf("title = %q", body["title"])
|
||||
}
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{
|
||||
"number": 99,
|
||||
"title": "Test issue",
|
||||
"state": "open",
|
||||
"updated_at": "2026-05-15T19:00:00Z",
|
||||
"html_url": "https://mgit.msbls.de/m/projax/issues/99",
|
||||
"labels": [{"name": "bug"}],
|
||||
"assignees": [{"login": "mAi"}]
|
||||
}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
iss, err := c.CreateIssue(context.Background(), "m", "projax", "Test issue", "body")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if iss.Number != 99 || iss.State != "open" {
|
||||
t.Errorf("issue = %+v", iss)
|
||||
}
|
||||
if len(iss.Labels) != 1 || iss.Labels[0] != "bug" {
|
||||
t.Errorf("labels = %v", iss.Labels)
|
||||
}
|
||||
if len(iss.Assignees) != 1 || iss.Assignees[0] != "mAi" {
|
||||
t.Errorf("assignees = %v", iss.Assignees)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommentRoundTrip(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/comments") {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["body"] != "First!" {
|
||||
t.Errorf("body = %q", body["body"])
|
||||
}
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{
|
||||
"id": 12345,
|
||||
"body": "First!",
|
||||
"html_url": "https://mgit.msbls.de/m/projax/issues/42#issuecomment-12345",
|
||||
"created_at": "2026-05-15T19:00:00Z"
|
||||
}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
cm, err := c.AddComment(context.Background(), "m", "projax", 42, "First!")
|
||||
if err != nil {
|
||||
t.Fatalf("AddComment: %v", err)
|
||||
}
|
||||
if cm.ID != 12345 || cm.Body != "First!" {
|
||||
t.Errorf("comment = %+v", cm)
|
||||
}
|
||||
want := time.Date(2026, 5, 15, 19, 0, 0, 0, time.UTC)
|
||||
if !cm.CreatedAt.Equal(want) {
|
||||
t.Errorf("CreatedAt = %v, want %v", cm.CreatedAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteback403ReturnsErrForbidden(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"message":"forbidden"}`, http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
if err := c.CloseIssue(context.Background(), "m", "projax", 1); err != ErrForbidden {
|
||||
t.Errorf("expected ErrForbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,18 @@ func (c *dashboardCache) invalidate(key string) {
|
||||
delete(c.rows, key)
|
||||
}
|
||||
|
||||
// invalidateAll wipes every cached payload. Used by writeback paths (Gitea
|
||||
// close/comment/create, CalDAV completion) that can change content under any
|
||||
// filter.
|
||||
func (c *dashboardCache) invalidateAll() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.rows = map[string]cachedDashboard{}
|
||||
}
|
||||
|
||||
func (c *dashboardCache) set(key string, p *dashboardPayload) {
|
||||
if c == nil {
|
||||
return
|
||||
|
||||
@@ -62,6 +62,15 @@ func (c *issueCache) get(key string) ([]gitea.Issue, bool) {
|
||||
return v.issues, true
|
||||
}
|
||||
|
||||
func (c *issueCache) invalidate(key string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.rows, key)
|
||||
}
|
||||
|
||||
func (c *issueCache) set(key string, issues []gitea.Issue) {
|
||||
if c == nil {
|
||||
return
|
||||
|
||||
173
web/gitea_writeback.go
Normal file
173
web/gitea_writeback.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/m/projax/gitea"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// handleIssueAction dispatches POST /i/{path}/issues/{action} where action is
|
||||
// close|reopen|comment|create. Form fields: repo (owner/repo), number
|
||||
// (optional for create), body (optional for create/comment).
|
||||
//
|
||||
// On success the handler busts the dashboard cache and re-renders the
|
||||
// detail-page issues_section partial so HTMX swaps it into place.
|
||||
func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path, action string) {
|
||||
if s.Gitea == nil {
|
||||
http.Error(w, "gitea not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
repoRef := strings.TrimSpace(r.FormValue("repo"))
|
||||
if repoRef == "" {
|
||||
http.Error(w, "repo required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Guard: repo must be linked to this item.
|
||||
if !s.repoLinkedToItem(r.Context(), it.ID, repoRef) {
|
||||
http.Error(w, "repo not linked to this item", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
owner, repo := gitea.ParseRepoRef(repoRef)
|
||||
if owner == "" || repo == "" {
|
||||
http.Error(w, "malformed repo ref", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
banner := ""
|
||||
switch action {
|
||||
case "close":
|
||||
num, ok := parseIssueNumber(r.FormValue("number"))
|
||||
if !ok {
|
||||
http.Error(w, "number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Gitea.Client.CloseIssue(r.Context(), owner, repo, num); err != nil {
|
||||
banner = giteaWritebackBanner("close", repoRef, err)
|
||||
}
|
||||
case "reopen":
|
||||
num, ok := parseIssueNumber(r.FormValue("number"))
|
||||
if !ok {
|
||||
http.Error(w, "number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Gitea.Client.ReopenIssue(r.Context(), owner, repo, num); err != nil {
|
||||
banner = giteaWritebackBanner("reopen", repoRef, err)
|
||||
}
|
||||
case "comment":
|
||||
num, ok := parseIssueNumber(r.FormValue("number"))
|
||||
if !ok {
|
||||
http.Error(w, "number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(r.FormValue("body"))
|
||||
if body == "" {
|
||||
banner = "Cannot post empty comment."
|
||||
break
|
||||
}
|
||||
if _, err := s.Gitea.Client.AddComment(r.Context(), owner, repo, num, body); err != nil {
|
||||
banner = giteaWritebackBanner("comment", repoRef, err)
|
||||
}
|
||||
case "create":
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" {
|
||||
banner = "Cannot create issue without a title."
|
||||
break
|
||||
}
|
||||
body := r.FormValue("body")
|
||||
if _, err := s.Gitea.Client.CreateIssue(r.Context(), owner, repo, title, body); err != nil {
|
||||
banner = giteaWritebackBanner("create", repoRef, err)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action: "+action, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Bust caches so the next fetch reflects the upstream change.
|
||||
s.Gitea.Cache.invalidate(repoRef + "|open")
|
||||
s.Gitea.Cache.invalidate(repoRef + "|closed-recent")
|
||||
if s.dashboard != nil {
|
||||
s.dashboard.invalidateAll()
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.renderIssuesSection(w, r, it, banner)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// renderIssuesSection re-fetches the issues for the item and renders the
|
||||
// issues_section partial. Used by HTMX swaps after a writeback.
|
||||
func (s *Server) renderIssuesSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
|
||||
issues, err := s.detailIssues(r.Context(), it)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
openTotal := 0
|
||||
for _, ri := range issues {
|
||||
openTotal += ri.OpenCount
|
||||
}
|
||||
s.render(w, "issues_section", map[string]any{
|
||||
"Item": it,
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Banner": banner,
|
||||
})
|
||||
}
|
||||
|
||||
// repoLinkedToItem checks that the given owner/repo ref is actually attached
|
||||
// to this item via a gitea-repo item_link. Prevents form-crafted writeback
|
||||
// against unrelated repos.
|
||||
func (s *Server) repoLinkedToItem(ctx context.Context, itemID, repoRef string) bool {
|
||||
links, err := s.Store.LinksByType(ctx, itemID, refTypeGiteaRepo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, l := range links {
|
||||
if l.RefID == repoRef {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseIssueNumber(s string) (int, bool) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// giteaWritebackBanner formats an inline error banner so the issues section
|
||||
// surfaces upstream failures (token lacks perms, repo not found, network)
|
||||
// without breaking the page render.
|
||||
func giteaWritebackBanner(action, repo string, err error) string {
|
||||
switch {
|
||||
case errors.Is(err, gitea.ErrForbidden):
|
||||
return "Could not " + action + " on " + repo + ": Gitea token lacks write access. Check GITEA_TOKEN_AI scope."
|
||||
case errors.Is(err, gitea.ErrNotFound):
|
||||
return "Repo " + repo + " not found on Gitea (renamed, deleted, or token lacks access)."
|
||||
}
|
||||
return "Could not " + action + " issue on " + repo + ": " + err.Error()
|
||||
}
|
||||
|
||||
// Server.repoLinkedToItem requires the handler to pass r.Context(); the
|
||||
// signature is plain context.Context so package importers (tests, other web
|
||||
// helpers) don't need to know which package the context came from.
|
||||
253
web/gitea_writeback_test.go
Normal file
253
web/gitea_writeback_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/gitea"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// gwbServer spins up a fake Gitea server with controllable responses for
|
||||
// close/reopen/comment/create + a passthrough issues list.
|
||||
type gwbServer struct {
|
||||
URL string
|
||||
closes atomic.Int32
|
||||
reopens atomic.Int32
|
||||
comments atomic.Int32
|
||||
creates atomic.Int32
|
||||
srv *httptest.Server
|
||||
repoOwner string
|
||||
repoName string
|
||||
}
|
||||
|
||||
func newGwbServer(t *testing.T, owner, repo string) *gwbServer {
|
||||
t.Helper()
|
||||
g := &gwbServer{repoOwner: owner, repoName: repo}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
_, _ = io.WriteString(w, "[]")
|
||||
case http.MethodPost:
|
||||
g.creates.Add(1)
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{"number": 1, "title": "`+body["title"]+`", "state": "open", "updated_at": "2026-05-15T19:00:00Z", "html_url": "https://x/1"}`)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPatch {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["state"] == "closed" {
|
||||
g.closes.Add(1)
|
||||
} else {
|
||||
g.reopens.Add(1)
|
||||
}
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
g.comments.Add(1)
|
||||
w.WriteHeader(201)
|
||||
_, _ = io.WriteString(w, `{"id": 1, "body": "test", "html_url": "https://x/1", "created_at": "2026-05-15T19:00:00Z"}`)
|
||||
})
|
||||
g.srv = httptest.NewServer(mux)
|
||||
g.URL = g.srv.URL
|
||||
t.Cleanup(func() { g.srv.Close() })
|
||||
return g
|
||||
}
|
||||
|
||||
// seedItemWithGiteaLink inserts a projax item under "dev" with a gitea-repo
|
||||
// link pointing at owner/repo, and returns the primary path + cleanup.
|
||||
func seedItemWithGiteaLink(t *testing.T, srv *web.Server, repoRef string) (path string, cleanup func()) {
|
||||
t.Helper()
|
||||
// Use the underlying pool directly via test helpers from server_test.go.
|
||||
pool := srv.Store.Pool
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "gwb-" + stamp
|
||||
var dev, id string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids, management)
|
||||
values (array['project']::text[], 'gwb', $1, ARRAY[$2]::uuid[], ARRAY['mai'])
|
||||
returning id`,
|
||||
slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'gitea-repo', $2, 'tracks')`,
|
||||
id, repoRef,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
_, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
}
|
||||
return "dev." + slug, cleanup
|
||||
}
|
||||
|
||||
func TestIssuesCloseRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
g := newGwbServer(t, "fake", "repo-close")
|
||||
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
|
||||
|
||||
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-close")
|
||||
defer cleanup()
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{}
|
||||
form.Set("repo", "fake/repo-close")
|
||||
form.Set("number", "42")
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/close", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("close → %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
if g.closes.Load() != 1 {
|
||||
t.Errorf("upstream close count = %d, want 1", g.closes.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesCommentRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
g := newGwbServer(t, "fake", "repo-comment")
|
||||
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
|
||||
|
||||
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-comment")
|
||||
defer cleanup()
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{}
|
||||
form.Set("repo", "fake/repo-comment")
|
||||
form.Set("number", "42")
|
||||
form.Set("body", "looks good")
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/comment", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("comment → %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
if g.comments.Load() != 1 {
|
||||
t.Errorf("upstream comment count = %d, want 1", g.comments.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesCreateRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
g := newGwbServer(t, "fake", "repo-create")
|
||||
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
|
||||
|
||||
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-create")
|
||||
defer cleanup()
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{}
|
||||
form.Set("repo", "fake/repo-create")
|
||||
form.Set("title", "New from projax")
|
||||
form.Set("body", "filed via /admin")
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("create → %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
if g.creates.Load() != 1 {
|
||||
t.Errorf("upstream create count = %d, want 1", g.creates.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesReopenRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
g := newGwbServer(t, "fake", "repo-reopen")
|
||||
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
|
||||
|
||||
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-reopen")
|
||||
defer cleanup()
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{}
|
||||
form.Set("repo", "fake/repo-reopen")
|
||||
form.Set("number", "42")
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/reopen", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("reopen → %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
if g.reopens.Load() != 1 {
|
||||
t.Errorf("upstream reopen count = %d, want 1", g.reopens.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesForbiddenRendersInlineBanner(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/fake/repo-403/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
// GET returns []; POST returns 403.
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = io.WriteString(w, "[]")
|
||||
return
|
||||
}
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
})
|
||||
fake := httptest.NewServer(mux)
|
||||
defer fake.Close()
|
||||
srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok"))
|
||||
|
||||
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-403")
|
||||
defer cleanup()
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{}
|
||||
form.Set("repo", "fake/repo-403")
|
||||
form.Set("title", "should 403")
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("expected graceful 200 with inline banner, got %d", w.Result().StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
if !strings.Contains(string(body), "lacks write access") {
|
||||
t.Errorf("expected 403 banner about token write access — body:\n%s", string(body))
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,13 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
return nil, fmt.Errorf("parse tasks_section: %w", err)
|
||||
}
|
||||
pages["tasks_section"] = tasksFragment
|
||||
// Standalone issues-section template for HTMX fragment responses (Phase 3h
|
||||
// writeback re-renders the issues card after a close/comment/create).
|
||||
issuesFragment, err := template.New("issues_section").Funcs(funcs).ParseFS(templatesFS, "templates/issues_section.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse issues_section: %w", err)
|
||||
}
|
||||
pages["issues_section"] = issuesFragment
|
||||
// Standalone documents-section template for HTMX fragment responses.
|
||||
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
|
||||
if err != nil {
|
||||
@@ -406,6 +413,12 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, action := range []string{"close", "reopen", "comment", "create"} {
|
||||
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
|
||||
s.handleIssueAction(w, r, base, action)
|
||||
return
|
||||
}
|
||||
}
|
||||
if base, ok := strings.CutSuffix(path, "/links/add"); ok {
|
||||
s.handleLinksAdd(w, r, base)
|
||||
return
|
||||
@@ -641,6 +654,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
|
||||
case "tasks_section":
|
||||
// HTMX fragment — no layout chrome.
|
||||
entry = "tasks-section"
|
||||
case "issues_section":
|
||||
entry = "issues-section"
|
||||
case "tree_section":
|
||||
entry = "tree-section"
|
||||
case "documents_section":
|
||||
|
||||
@@ -252,3 +252,21 @@ table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; }
|
||||
.dashboard .stale-row:last-child { border-bottom: none; }
|
||||
.dashboard .stale-row .repo { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; }
|
||||
.dashboard .stale-row .last-active { color: var(--warn); font-size: 0.9em; }
|
||||
|
||||
/* --- Issues writeback (3h) --- */
|
||||
.issues .new-issue { margin: 8px 0; }
|
||||
.issues .new-issue-form, .issues .comment-form {
|
||||
display: flex; flex-direction: column; gap: 4px; padding: 6px 0;
|
||||
}
|
||||
.issues .new-issue-form input[name=title] { width: 100%; }
|
||||
.issues .new-issue-form textarea, .issues .comment-form textarea {
|
||||
width: 100%; resize: vertical; font-family: inherit; padding: 4px;
|
||||
}
|
||||
.issues .issue-row form { display: inline-flex; align-items: center; margin-left: 4px; }
|
||||
.issues .issue-row form button {
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 3px;
|
||||
padding: 1px 6px; cursor: pointer; color: var(--muted); font-size: 0.85em;
|
||||
}
|
||||
.issues .issue-close button:hover { color: var(--ok); border-color: var(--ok); }
|
||||
.issues .issue-reopen button:hover { color: var(--warn); border-color: var(--warn); }
|
||||
.issues .issue-comment summary { font-size: 0.85em; cursor: pointer; }
|
||||
|
||||
@@ -1,23 +1,59 @@
|
||||
{{define "issues-section"}}
|
||||
<section class="issues" id="issues-section">
|
||||
<h2>Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2>
|
||||
{{if .Banner}}<p class="banner warn">{{.Banner}}</p>{{end}}
|
||||
{{range .Issues}}
|
||||
{{$repo := .Repo}}
|
||||
<div class="repo-block" data-repo="{{.Repo}}">
|
||||
<h3>
|
||||
<a href="{{.RepoURL}}" target="_blank" rel="noopener noreferrer">{{.Repo}}</a>
|
||||
<small class="muted"><a href="{{.IssuesURL}}" target="_blank" rel="noopener noreferrer">↗ Gitea repo</a></small>
|
||||
</h3>
|
||||
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}}
|
||||
|
||||
<details class="new-issue">
|
||||
<summary class="muted">+ new issue</summary>
|
||||
<form class="new-issue-form"
|
||||
hx-post="/i/{{$.Item.PrimaryPath}}/issues/create"
|
||||
hx-target="#issues-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="repo" value="{{.Repo}}">
|
||||
<input name="title" placeholder="title" required>
|
||||
<textarea name="body" placeholder="body (markdown)" rows="3"></textarea>
|
||||
<button type="submit">create</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{if .Open}}
|
||||
<ul class="issues open">
|
||||
{{range .Open}}
|
||||
<li class="issue-row">
|
||||
<li class="issue-row" id="issue-{{$repo}}-{{.Number}}">
|
||||
<a class="num" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">#{{.Number}}</a>
|
||||
<a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
|
||||
{{range .Labels}}<span class="label">{{.}}</span>{{end}}
|
||||
{{if .Milestone}}<span class="milestone">{{.Milestone}}</span>{{end}}
|
||||
{{range .Assignees}}<span class="assignee">@{{.}}</span>{{end}}
|
||||
{{if .UpdatedRel}}<small class="muted">updated {{.UpdatedRel}}</small>{{end}}
|
||||
<form class="issue-close"
|
||||
hx-post="/i/{{$.Item.PrimaryPath}}/issues/close"
|
||||
hx-target="#issues-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="repo" value="{{$repo}}">
|
||||
<input type="hidden" name="number" value="{{.Number}}">
|
||||
<button type="submit" title="close issue">✓ close</button>
|
||||
</form>
|
||||
<details class="issue-comment">
|
||||
<summary class="muted">comment</summary>
|
||||
<form class="comment-form"
|
||||
hx-post="/i/{{$.Item.PrimaryPath}}/issues/comment"
|
||||
hx-target="#issues-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="repo" value="{{$repo}}">
|
||||
<input type="hidden" name="number" value="{{.Number}}">
|
||||
<textarea name="body" placeholder="comment (markdown)" rows="2" required></textarea>
|
||||
<button type="submit">post</button>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -34,6 +70,14 @@
|
||||
<a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
|
||||
{{range .Labels}}<span class="label">{{.}}</span>{{end}}
|
||||
{{if .UpdatedRel}}<small class="muted">{{.UpdatedRel}}</small>{{end}}
|
||||
<form class="issue-reopen"
|
||||
hx-post="/i/{{$.Item.PrimaryPath}}/issues/reopen"
|
||||
hx-target="#issues-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="repo" value="{{$repo}}">
|
||||
<input type="hidden" name="number" value="{{.Number}}">
|
||||
<button type="submit" title="reopen">↻ reopen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user