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

99 lines
3.1 KiB
Go

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
}