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:
mAi
2026-05-15 19:22:11 +02:00
parent b159a7dc01
commit 5a56ad91e5
11 changed files with 788 additions and 5 deletions

View File

@@ -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. 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. - **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. - **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. - **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. - **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. - **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. 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.

View File

@@ -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("Authorization", "token "+c.Token)
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
return c.HTTPClient.Do(req) 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 // 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 // resource. Most often this surfaces when the linked owner/repo no longer
// exists (renamed, archived, deleted). // 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. // the status code and (trimmed) body so the caller can log it.
func readErr(resp *http.Response, op string) error { func readErr(resp *http.Response, op string) error {
raw, _ := io.ReadAll(resp.Body) raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound { switch resp.StatusCode {
case http.StatusNotFound:
return ErrNotFound return ErrNotFound
case http.StatusUnauthorized, http.StatusForbidden:
return ErrForbidden
} }
return fmt.Errorf("gitea %s: %d %s", op, resp.StatusCode, strings.TrimSpace(string(raw))) return fmt.Errorf("gitea %s: %d %s", op, resp.StatusCode, strings.TrimSpace(string(raw)))
} }

98
gitea/writeback.go Normal file
View 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
View 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)
}
}

View File

@@ -58,6 +58,18 @@ func (c *dashboardCache) invalidate(key string) {
delete(c.rows, key) 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) { func (c *dashboardCache) set(key string, p *dashboardPayload) {
if c == nil { if c == nil {
return return

View File

@@ -62,6 +62,15 @@ func (c *issueCache) get(key string) ([]gitea.Issue, bool) {
return v.issues, true 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) { func (c *issueCache) set(key string, issues []gitea.Issue) {
if c == nil { if c == nil {
return return

173
web/gitea_writeback.go Normal file
View 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
View 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))
}
}

View File

@@ -126,6 +126,13 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
return nil, fmt.Errorf("parse tasks_section: %w", err) return nil, fmt.Errorf("parse tasks_section: %w", err)
} }
pages["tasks_section"] = tasksFragment 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. // Standalone documents-section template for HTMX fragment responses.
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl") docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
if err != nil { if err != nil {
@@ -406,6 +413,12 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
return 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 { if base, ok := strings.CutSuffix(path, "/links/add"); ok {
s.handleLinksAdd(w, r, base) s.handleLinksAdd(w, r, base)
return return
@@ -641,6 +654,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
case "tasks_section": case "tasks_section":
// HTMX fragment — no layout chrome. // HTMX fragment — no layout chrome.
entry = "tasks-section" entry = "tasks-section"
case "issues_section":
entry = "issues-section"
case "tree_section": case "tree_section":
entry = "tree-section" entry = "tree-section"
case "documents_section": case "documents_section":

View File

@@ -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:last-child { border-bottom: none; }
.dashboard .stale-row .repo { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; } .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; } .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; }

View File

@@ -1,23 +1,59 @@
{{define "issues-section"}} {{define "issues-section"}}
<section class="issues" id="issues-section"> <section class="issues" id="issues-section">
<h2>Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2> <h2>Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2>
{{if .Banner}}<p class="banner warn">{{.Banner}}</p>{{end}}
{{range .Issues}} {{range .Issues}}
{{$repo := .Repo}}
<div class="repo-block" data-repo="{{.Repo}}"> <div class="repo-block" data-repo="{{.Repo}}">
<h3> <h3>
<a href="{{.RepoURL}}" target="_blank" rel="noopener noreferrer">{{.Repo}}</a> <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> <small class="muted"><a href="{{.IssuesURL}}" target="_blank" rel="noopener noreferrer">↗ Gitea repo</a></small>
</h3> </h3>
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}} {{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}} {{if .Open}}
<ul class="issues open"> <ul class="issues open">
{{range .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="num" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">#{{.Number}}</a>
<a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a> <a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
{{range .Labels}}<span class="label">{{.}}</span>{{end}} {{range .Labels}}<span class="label">{{.}}</span>{{end}}
{{if .Milestone}}<span class="milestone">{{.Milestone}}</span>{{end}} {{if .Milestone}}<span class="milestone">{{.Milestone}}</span>{{end}}
{{range .Assignees}}<span class="assignee">@{{.}}</span>{{end}} {{range .Assignees}}<span class="assignee">@{{.}}</span>{{end}}
{{if .UpdatedRel}}<small class="muted">updated {{.UpdatedRel}}</small>{{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> </li>
{{end}} {{end}}
</ul> </ul>
@@ -34,6 +70,14 @@
<a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a> <a class="title" href="{{.HTMLURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
{{range .Labels}}<span class="label">{{.}}</span>{{end}} {{range .Labels}}<span class="label">{{.}}</span>{{end}}
{{if .UpdatedRel}}<small class="muted">{{.UpdatedRel}}</small>{{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> </li>
{{end}} {{end}}
</ul> </ul>