Merge branch 'mai/knuth/phase-2-d-gitea-ingest'

This commit is contained in:
mAi
2026-05-15 17:27:07 +02:00
12 changed files with 735 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/m/projax/caldav"
"github.com/m/projax/db"
"github.com/m/projax/gitea"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
@@ -90,6 +91,18 @@ func main() {
logger.Info("caldav: disabled — DAV_URL not set")
}
if giteaURL := os.Getenv("GITEA_URL"); giteaURL != "" {
giteaToken := os.Getenv("GITEA_TOKEN")
if giteaToken == "" {
logger.Error("GITEA_URL set but GITEA_TOKEN missing — refusing to start")
os.Exit(1)
}
srv.Gitea = web.NewGiteaDeps(gitea.New(giteaURL, giteaToken))
logger.Info("gitea: enabled", "base_url", giteaURL)
} else {
logger.Info("gitea: disabled — GITEA_URL not set")
}
httpServer := &http.Server{
Addr: listen,
Handler: srv.Routes(),

View File

@@ -39,8 +39,10 @@ env:
- PROJAX_AUTO_MIGRATE=on
- SUPABASE_URL=https://supa.flexsiebels.de
- DAV_URL=https://dav.msbls.de/dav/calendars/m/
- GITEA_URL=https://mgit.msbls.de
secrets:
- PROJAX_DB_URL
- SUPABASE_ANON_KEY
- DAV_USER
- DAV_PASSWORD
- GITEA_TOKEN # = GITEA_TOKEN_AI from .env.age (mAi automation account)

View File

@@ -251,6 +251,19 @@ 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)
m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). projax v1 reads but does not write:
- **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.
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.
## 8. Open questions (post-PRD)
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.

68
gitea/client.go Normal file
View File

@@ -0,0 +1,68 @@
// Package gitea is a minimal client for the slice of Gitea that projax needs:
// list open + recently-closed issues on a repo. Read-only at v1 — writeback
// is parked until phase 2.e.
package gitea
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client wraps a base URL + automation-account token.
type Client struct {
BaseURL string // e.g. https://mgit.msbls.de
Token string
HTTPClient *http.Client
}
// New builds a Client with a sensible default timeout. Token is the
// automation-account API token (Authorization: token <…>). base must NOT
// include a trailing /api/v1 — the client adds the API prefix itself.
func New(base, token string) *Client {
base = strings.TrimRight(base, "/")
return &Client{
BaseURL: base,
Token: token,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
// do issues a single request, attaches token auth, and returns the raw
// response for the caller to decode.
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
u := c.BaseURL + "/api/v1" + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body))
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "token "+c.Token)
}
req.Header.Set("Accept", "application/json")
return c.HTTPClient.Do(req)
}
// ErrNotFound is returned when the Gitea API responds 404 for a repo or
// resource. Most often this surfaces when the linked owner/repo no longer
// exists (renamed, archived, deleted).
var ErrNotFound = errors.New("gitea: not found")
// readErr collapses a non-2xx Gitea response into a single error containing
// the status code and (trimmed) body so the caller can log it.
func readErr(resp *http.Response, op string) error {
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
return fmt.Errorf("gitea %s: %d %s", op, resp.StatusCode, strings.TrimSpace(string(raw)))
}

128
gitea/issues.go Normal file
View File

@@ -0,0 +1,128 @@
package gitea
import (
"context"
"encoding/json"
"net/url"
"strconv"
"strings"
"time"
)
// Issue is the slice of /repos/{o}/{r}/issues that projax renders.
type Issue struct {
Number int // Gitea-local issue number, e.g. 223
Title string // free text — display as-is
State string // "open" | "closed"
Labels []string // label names, ordered by Gitea's response
Assignees []string // login names
Milestone string // milestone title, or "" if none
UpdatedAt time.Time
ClosedAt *time.Time
HTMLURL string // browser URL on the Gitea instance
}
// ListOpts narrows ListIssues.
type ListOpts struct {
State string // "open" (default) | "closed" | "all"
Limit int // page size, default 50; Gitea caps at 50 per page
Since time.Time
}
// raw mirrors the Gitea API response. Only fields projax surfaces are mapped;
// pull_request is read so we can drop merged PRs that snuck past type=issues
// on older Gitea releases.
type rawIssue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at"`
PullRequest *struct{} `json:"pull_request"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Assignees []struct {
Login string `json:"login"`
} `json:"assignees"`
Milestone *struct {
Title string `json:"title"`
} `json:"milestone"`
}
// ListIssues fetches issues for owner/repo. type=issues filters PRs out
// server-side on Gitea ≥1.20; we also drop any rawIssue with pull_request set
// as a belt-and-braces safety. Returns ErrNotFound if the repo doesn't exist.
func (c *Client) ListIssues(ctx context.Context, owner, repo string, opts ListOpts) ([]Issue, error) {
q := url.Values{}
q.Set("type", "issues")
state := strings.ToLower(strings.TrimSpace(opts.State))
if state == "" {
state = "open"
}
q.Set("state", state)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
q.Set("limit", strconv.Itoa(limit))
if !opts.Since.IsZero() {
q.Set("since", opts.Since.UTC().Format(time.RFC3339))
}
resp, err := c.do(ctx, "GET", "/repos/"+owner+"/"+repo+"/issues", q, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, readErr(resp, "issues")
}
var raws []rawIssue
if err := json.NewDecoder(resp.Body).Decode(&raws); err != nil {
return nil, err
}
out := make([]Issue, 0, len(raws))
for _, r := range raws {
if r.PullRequest != nil {
continue // PR slipped past type=issues
}
iss := Issue{
Number: r.Number,
Title: r.Title,
State: r.State,
HTMLURL: r.HTMLURL,
UpdatedAt: r.UpdatedAt,
ClosedAt: r.ClosedAt,
}
for _, l := range r.Labels {
iss.Labels = append(iss.Labels, l.Name)
}
for _, a := range r.Assignees {
iss.Assignees = append(iss.Assignees, a.Login)
}
if r.Milestone != nil {
iss.Milestone = r.Milestone.Title
}
out = append(out, iss)
}
return out, nil
}
// ParseRepoRef splits "owner/repo" into its halves. Returns the empty strings
// for a malformed ref so callers can skip it without a panic.
func ParseRepoRef(ref string) (owner, repo string) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", ""
}
if i := strings.IndexByte(ref, '/'); i > 0 && i < len(ref)-1 {
return ref[:i], ref[i+1:]
}
return "", ""
}
// RepoHTMLURL returns the browser URL for owner/repo on this Gitea instance.
func (c *Client) RepoHTMLURL(owner, repo string) string {
return c.BaseURL + "/" + owner + "/" + repo
}

143
gitea/issues_test.go Normal file
View File

@@ -0,0 +1,143 @@
package gitea
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newFakeGitea(t *testing.T, body string) (*Client, *httptest.Server) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/m/projax/issues", func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Header.Get("Authorization"), "token tok-123"; got != want {
t.Errorf("Authorization = %q, want %q", got, want)
}
if r.URL.Query().Get("type") != "issues" {
t.Errorf("type query = %q", r.URL.Query().Get("type"))
}
w.WriteHeader(200)
_, _ = io.WriteString(w, body)
})
mux.HandleFunc("/api/v1/repos/missing/repo/issues", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "{}", http.StatusNotFound)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := New(srv.URL, "tok-123")
return c, srv
}
func TestListIssuesParse(t *testing.T) {
body := `[
{
"number": 223,
"title": "Integration tests leak project rows",
"state": "open",
"html_url": "https://mgit.msbls.de/m/projax/issues/223",
"updated_at": "2026-05-15T13:02:33Z",
"labels": [
{"name": "bug"},
{"name": "done"}
],
"assignees": [
{"login": "mAi"},
{"login": "knuth"}
],
"milestone": {"title": "Phase 2"}
},
{
"number": 224,
"title": "A PR that snuck through",
"state": "open",
"html_url": "https://mgit.msbls.de/m/projax/pulls/224",
"updated_at": "2026-05-15T13:00:00Z",
"pull_request": {}
}
]`
c, _ := newFakeGitea(t, body)
got, err := c.ListIssues(context.Background(), "m", "projax", ListOpts{State: "open"})
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 issue (PR filtered out), got %d (%+v)", len(got), got)
}
iss := got[0]
if iss.Number != 223 || iss.Title != "Integration tests leak project rows" {
t.Errorf("issue mismatch: %+v", iss)
}
if got, want := iss.Labels, []string{"bug", "done"}; !equalStrings(got, want) {
t.Errorf("Labels = %v, want %v", got, want)
}
if got, want := iss.Assignees, []string{"mAi", "knuth"}; !equalStrings(got, want) {
t.Errorf("Assignees = %v, want %v", got, want)
}
if iss.Milestone != "Phase 2" {
t.Errorf("Milestone = %q", iss.Milestone)
}
if iss.UpdatedAt.IsZero() {
t.Error("UpdatedAt zero")
}
}
func TestListIssuesNotFound(t *testing.T) {
c, _ := newFakeGitea(t, "[]")
_, err := c.ListIssues(context.Background(), "missing", "repo", ListOpts{})
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestParseRepoRef(t *testing.T) {
for _, tc := range []struct {
in, owner, repo string
}{
{"m/projax", "m", "projax"},
{"HL/mWorkRepo", "HL", "mWorkRepo"},
{" m/mAi ", "m", "mAi"},
{"missing-slash", "", ""},
{"/leading", "", ""},
{"trailing/", "", ""},
{"", "", ""},
} {
o, r := ParseRepoRef(tc.in)
if o != tc.owner || r != tc.repo {
t.Errorf("ParseRepoRef(%q) = (%q,%q), want (%q,%q)", tc.in, o, r, tc.owner, tc.repo)
}
}
}
func TestRepoHTMLURL(t *testing.T) {
c := New("https://mgit.msbls.de/", "")
if got, want := c.RepoHTMLURL("m", "projax"), "https://mgit.msbls.de/m/projax"; got != want {
t.Errorf("RepoHTMLURL = %q, want %q", got, want)
}
}
// Smoke: 5s timeout should be the default.
func TestDefaultTimeout(t *testing.T) {
c := New("https://example", "")
if c.HTTPClient.Timeout != 5*time.Second {
t.Errorf("Timeout = %v, want 5s", c.HTTPClient.Timeout)
}
if !strings.HasPrefix(c.BaseURL, "https://example") {
t.Errorf("BaseURL = %q", c.BaseURL)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

216
web/gitea.go Normal file
View File

@@ -0,0 +1,216 @@
package web
import (
"context"
"errors"
"strconv"
"sync"
"time"
"github.com/m/projax/gitea"
"github.com/m/projax/store"
)
const refTypeGiteaRepo = "gitea-repo"
// GiteaDeps is the optional Gitea integration. nil → the Issues section on
// the detail page renders nothing and main.go logs "gitea: disabled".
type GiteaDeps struct {
Client *gitea.Client
// Cache is a small in-memory TTL cache so repeatedly rendering the same
// detail page does not hammer Gitea. Nil → no caching (used in tests).
Cache *issueCache
}
// NewGiteaDeps wires a client + a default 3-minute TTL cache.
func NewGiteaDeps(c *gitea.Client) *GiteaDeps {
return &GiteaDeps{Client: c, Cache: newIssueCache(3 * time.Minute)}
}
// issueCache is a tiny synchronised TTL cache keyed by "owner/repo|state".
// Concurrency-safe; not size-bounded (m's project count is small, so we
// don't bother with LRU at v1).
type issueCache struct {
ttl time.Duration
mu sync.Mutex
rows map[string]cachedIssues
}
type cachedIssues struct {
at time.Time
issues []gitea.Issue
}
func newIssueCache(ttl time.Duration) *issueCache {
return &issueCache{ttl: ttl, rows: map[string]cachedIssues{}}
}
func (c *issueCache) get(key string) ([]gitea.Issue, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.rows[key]
if !ok {
return nil, false
}
if time.Since(v.at) > c.ttl {
delete(c.rows, key)
return nil, false
}
return v.issues, true
}
func (c *issueCache) set(key string, issues []gitea.Issue) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows[key] = cachedIssues{at: time.Now(), issues: issues}
}
// repoIssues groups one repo's open + recently-closed issues for the template.
type repoIssues struct {
Repo string // owner/repo as supplied in the item link
RepoURL string // browser URL for the repo
IssuesURL string // browser URL for the open-issues list page
Open []giteaIssueView
ClosedRecent []giteaIssueView
OpenCount int
Error string
}
// giteaIssueView is the template-facing view of an issue — pre-rendered fields
// so the template doesn't have to do formatting work.
type giteaIssueView struct {
Number int
Title string
State string
Labels []string
Assignees []string
Milestone string
UpdatedAt time.Time
HTMLURL string
UpdatedRel string // "2h ago", "yesterday", "3d ago" — pre-formatted for the row
}
func toView(in []gitea.Issue, now time.Time) []giteaIssueView {
out := make([]giteaIssueView, 0, len(in))
for _, i := range in {
out = append(out, giteaIssueView{
Number: i.Number,
Title: i.Title,
State: i.State,
Labels: i.Labels,
Assignees: i.Assignees,
Milestone: i.Milestone,
UpdatedAt: i.UpdatedAt,
HTMLURL: i.HTMLURL,
UpdatedRel: relativeTime(now, i.UpdatedAt),
})
}
return out
}
// relativeTime renders a short "Nh ago" / "yesterday" / "Nd ago" string. Days
// past 30 fall back to a plain YYYY-MM-DD so old issues stay readable.
func relativeTime(now, t time.Time) string {
if t.IsZero() {
return ""
}
d := now.Sub(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return formatDuration(d, time.Minute, "m") + " ago"
case d < 24*time.Hour:
return formatDuration(d, time.Hour, "h") + " ago"
case d < 48*time.Hour:
return "yesterday"
case d < 30*24*time.Hour:
return formatDuration(d, 24*time.Hour, "d") + " ago"
default:
return t.UTC().Format("2006-01-02")
}
}
func formatDuration(d, unit time.Duration, suffix string) string {
n := int(d / unit)
if n < 1 {
n = 1
}
return strconv.Itoa(n) + suffix
}
// detailIssues walks every gitea-repo link on the item, fetches open issues
// (and the last 30d of closed ones into a small disclosure), and pre-renders
// each row for the template. Per-repo failures surface as repoIssues.Error so
// one missing/renamed repo doesn't blank the whole section.
func (s *Server) detailIssues(ctx context.Context, item *store.Item) ([]repoIssues, error) {
if s.Gitea == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeGiteaRepo)
if err != nil {
return nil, err
}
if len(links) == 0 {
return nil, nil
}
now := time.Now()
closedCutoff := now.AddDate(0, 0, -30)
var out []repoIssues
for _, l := range links {
owner, repo := gitea.ParseRepoRef(l.RefID)
ri := repoIssues{
Repo: l.RefID,
RepoURL: s.Gitea.Client.RepoHTMLURL(owner, repo),
IssuesURL: s.Gitea.Client.RepoHTMLURL(owner, repo) + "/issues",
}
if owner == "" || repo == "" {
ri.Error = "Malformed repo ref — expected owner/repo, got " + l.RefID
out = append(out, ri)
continue
}
openKey := l.RefID + "|open"
open, ok := s.Gitea.Cache.get(openKey)
if !ok {
open, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "open"})
if err != nil {
ri.Error = giteaErrMessage(l.RefID, err)
s.Logger.Warn("gitea list issues", "repo", l.RefID, "err", err)
out = append(out, ri)
continue
}
s.Gitea.Cache.set(openKey, open)
}
ri.Open = toView(open, now)
ri.OpenCount = len(ri.Open)
// Closed (last 30d, up to 20).
closedKey := l.RefID + "|closed-recent"
closed, ok := s.Gitea.Cache.get(closedKey)
if !ok {
closed, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "closed", Since: closedCutoff, Limit: 20})
if err != nil {
// Closed-list failure is non-fatal — the open list already rendered.
s.Logger.Warn("gitea list closed", "repo", l.RefID, "err", err)
closed = nil
} else {
s.Gitea.Cache.set(closedKey, closed)
}
}
ri.ClosedRecent = toView(closed, now)
out = append(out, ri)
}
return out, nil
}
func giteaErrMessage(repo string, err error) string {
if errors.Is(err, gitea.ErrNotFound) {
return "Repo " + repo + " not found on Gitea (renamed, deleted, or token lacks access)."
}
return "Could not fetch issues from " + repo + ": " + err.Error()
}

66
web/gitea_test.go Normal file
View File

@@ -0,0 +1,66 @@
package web
import (
"testing"
"time"
"github.com/m/projax/gitea"
)
func TestIssueCacheTTL(t *testing.T) {
c := newIssueCache(50 * time.Millisecond)
c.set("k", []gitea.Issue{{Number: 1, Title: "a"}})
if got, ok := c.get("k"); !ok || len(got) != 1 {
t.Fatalf("immediate get: ok=%v got=%v", ok, got)
}
time.Sleep(80 * time.Millisecond)
if _, ok := c.get("k"); ok {
t.Errorf("expected miss after TTL, got hit")
}
}
func TestRelativeTime(t *testing.T) {
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
offset time.Duration
want string
}{
{-30 * time.Second, "just now"},
{-5 * time.Minute, "5m ago"},
{-3 * time.Hour, "3h ago"},
{-30 * time.Hour, "yesterday"},
{-5 * 24 * time.Hour, "5d ago"},
} {
got := relativeTime(now, now.Add(tc.offset))
if got != tc.want {
t.Errorf("offset=%v got %q want %q", tc.offset, got, tc.want)
}
}
}
func TestToViewPreservesFields(t *testing.T) {
in := []gitea.Issue{
{
Number: 7,
Title: "x",
State: "open",
Labels: []string{"bug"},
Assignees: []string{"a"},
Milestone: "M",
HTMLURL: "https://example/7",
UpdatedAt: time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC),
},
}
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
got := toView(in, now)
if len(got) != 1 {
t.Fatalf("expected 1, got %d", len(got))
}
v := got[0]
if v.Number != 7 || v.Title != "x" || v.HTMLURL != "https://example/7" {
t.Errorf("field mismatch: %+v", v)
}
if v.UpdatedRel != "yesterday" {
t.Errorf("UpdatedRel = %q, want yesterday", v.UpdatedRel)
}
}

View File

@@ -28,6 +28,7 @@ type Server struct {
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
}
// New builds a Server. Each page is parsed alongside the layout into its own
@@ -83,12 +84,13 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages[name] = t
}
// detail bundles the shared tasks-section partial so HTMX swaps and the
// initial page render hit the same template definition.
// detail bundles the shared tasks-section + issues-section partials so
// HTMX swaps and the initial page render hit the same template definitions.
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/detail.tmpl",
"templates/tasks_section.tmpl",
"templates/issues_section.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse detail: %w", err)
@@ -197,13 +199,24 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
issues, err := s.detailIssues(r.Context(), it)
if err != nil {
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
}
openTotal := 0
for _, ri := range issues {
openTotal += ri.OpenCount
}
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
})
}

View File

@@ -90,3 +90,19 @@ table.classify input, table.classify select { width: 100%; }
.tasks .todo-create input[type="text"] { flex: 1; }
.tasks ul.done .summary { color: var(--muted); text-decoration: line-through; flex: 1; }
.banner.warn { background: #fff5e6; border: 1px solid var(--warn); color: var(--warn); padding: 6px 10px; border-radius: 4px; margin: 8px 0; }
/* Issues section — Gitea-issue ingest (phase 2.d). */
.issues .repo-block { border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; margin: 8px 0 16px; background: #fff; }
.issues .repo-block h3 { font-size: 0.95em; margin: 0 0 8px; display: flex; gap: 12px; align-items: baseline; }
.issues .repo-block h3 a { color: var(--accent); text-decoration: none; }
.issues .repo-block h3 a:hover { text-decoration: underline; }
.issues ul.issues { list-style: none; padding: 0; margin: 0; }
.issues li.issue-row { display: flex; gap: 6px; align-items: baseline; padding: 4px 0; border-bottom: 1px dotted var(--border); flex-wrap: wrap; }
.issues li.issue-row:last-child { border-bottom: none; }
.issues li.issue-row .num { color: var(--muted); font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; text-decoration: none; }
.issues li.issue-row .title { color: var(--fg); text-decoration: none; flex: 1; min-width: 12em; }
.issues li.issue-row .title:hover { text-decoration: underline; color: var(--accent); }
.issues li.issue-row .label { display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 999px; background: var(--bg-alt); border: 1px solid var(--border); color: var(--accent); }
.issues li.issue-row .milestone { font-size: 0.72em; padding: 1px 6px; border-radius: 4px; background: #fff; border: 1px solid var(--border); color: var(--warn); }
.issues li.issue-row .assignee { font-size: 0.78em; color: var(--muted); }
.issues ul.closed .title { color: var(--muted); }

View File

@@ -17,6 +17,10 @@
{{template "tasks-section" .}}
{{end}}
{{if and .GiteaOn .Issues}}
{{template "issues-section" .}}
{{end}}
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>

View File

@@ -0,0 +1,45 @@
{{define "issues-section"}}
<section class="issues" id="issues-section">
<h2>Issues{{if .IssuesOpenTotal}} ({{.IssuesOpenTotal}}){{end}}</h2>
{{range .Issues}}
<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}}
{{if .Open}}
<ul class="issues open">
{{range .Open}}
<li class="issue-row">
<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}}
</li>
{{end}}
</ul>
{{else if not .Error}}
<p class="muted">No open issues.</p>
{{end}}
{{if .ClosedRecent}}
<details>
<summary class="muted">{{len .ClosedRecent}} closed in last 30 days</summary>
<ul class="issues closed">
{{range .ClosedRecent}}
<li class="issue-row">
<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 .UpdatedRel}}<small class="muted">{{.UpdatedRel}}</small>{{end}}
</li>
{{end}}
</ul>
</details>
{{end}}
</div>
{{end}}
</section>
{{end}}