From 5a56ad91e50673be40b8cbd9b2e440c8fc2ab2ab Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 19:22:11 +0200 Subject: [PATCH] feat(phase 3h gitea writeback): close/reopen/comment/create from projax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/design.md | 14 +- gitea/client.go | 14 +- gitea/writeback.go | 98 ++++++++++++ gitea/writeback_test.go | 141 +++++++++++++++++ web/dashboard.go | 12 ++ web/gitea.go | 9 ++ web/gitea_writeback.go | 173 ++++++++++++++++++++ web/gitea_writeback_test.go | 253 ++++++++++++++++++++++++++++++ web/server.go | 15 ++ web/static/style.css | 18 +++ web/templates/issues_section.tmpl | 46 +++++- 11 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 gitea/writeback.go create mode 100644 gitea/writeback_test.go create mode 100644 web/gitea_writeback.go create mode 100644 web/gitea_writeback_test.go diff --git a/docs/design.md b/docs/design.md index 2774d66..3a752c6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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='/'` (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 `), 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 `. 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. diff --git a/gitea/client.go b/gitea/client.go index d1c6ec8..077cf67 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -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))) } diff --git a/gitea/writeback.go b/gitea/writeback.go new file mode 100644 index 0000000..af3aefc --- /dev/null +++ b/gitea/writeback.go @@ -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 +} diff --git a/gitea/writeback_test.go b/gitea/writeback_test.go new file mode 100644 index 0000000..5b0f9a9 --- /dev/null +++ b/gitea/writeback_test.go @@ -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) + } +} diff --git a/web/dashboard.go b/web/dashboard.go index c055dd3..6444031 100644 --- a/web/dashboard.go +++ b/web/dashboard.go @@ -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 diff --git a/web/gitea.go b/web/gitea.go index 8a8579b..a1f9b0c 100644 --- a/web/gitea.go +++ b/web/gitea.go @@ -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 diff --git a/web/gitea_writeback.go b/web/gitea_writeback.go new file mode 100644 index 0000000..63ae17c --- /dev/null +++ b/web/gitea_writeback.go @@ -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. diff --git a/web/gitea_writeback_test.go b/web/gitea_writeback_test.go new file mode 100644 index 0000000..ebddd7f --- /dev/null +++ b/web/gitea_writeback_test.go @@ -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)) + } +} diff --git a/web/server.go b/web/server.go index 588793b..7c302c9 100644 --- a/web/server.go +++ b/web/server.go @@ -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": diff --git a/web/static/style.css b/web/static/style.css index 6bd35b4..8b0e047 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; } diff --git a/web/templates/issues_section.tmpl b/web/templates/issues_section.tmpl index 7a1ccbf..2b21d63 100644 --- a/web/templates/issues_section.tmpl +++ b/web/templates/issues_section.tmpl @@ -1,23 +1,59 @@ {{define "issues-section"}}

Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}

+ {{if .Banner}}{{end}} {{range .Issues}} + {{$repo := .Repo}}

{{.Repo}} ↗ Gitea repo

{{if .Error}}{{end}} + +
+ + new issue +
+ + + + +
+
+ {{if .Open}}
    {{range .Open}} -
  • +
  • #{{.Number}} {{.Title}} {{range .Labels}}{{.}}{{end}} {{if .Milestone}}{{.Milestone}}{{end}} {{range .Assignees}}@{{.}}{{end}} {{if .UpdatedRel}}updated {{.UpdatedRel}}{{end}} +
    + + + +
    +
    + comment +
    + + + + +
    +
  • {{end}}
@@ -34,6 +70,14 @@ {{.Title}} {{range .Labels}}{{.}}{{end}} {{if .UpdatedRel}}{{.UpdatedRel}}{{end}} +
+ + + +
{{end}}