diff --git a/cmd/projax/main.go b/cmd/projax/main.go index 89f5d98..0dbd72c 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -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(), diff --git a/deploy/dokploy.yaml b/deploy/dokploy.yaml index f393b9f..a5b080e 100644 --- a/deploy/dokploy.yaml +++ b/deploy/dokploy.yaml @@ -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) diff --git a/docs/design.md b/docs/design.md index 39c6feb..6885832 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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='/'` (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. + +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. diff --git a/gitea/client.go b/gitea/client.go new file mode 100644 index 0000000..d1c6ec8 --- /dev/null +++ b/gitea/client.go @@ -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))) +} diff --git a/gitea/issues.go b/gitea/issues.go new file mode 100644 index 0000000..dbd0d4a --- /dev/null +++ b/gitea/issues.go @@ -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 +} diff --git a/gitea/issues_test.go b/gitea/issues_test.go new file mode 100644 index 0000000..742db74 --- /dev/null +++ b/gitea/issues_test.go @@ -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 +} diff --git a/web/gitea.go b/web/gitea.go new file mode 100644 index 0000000..8a8579b --- /dev/null +++ b/web/gitea.go @@ -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() +} diff --git a/web/gitea_test.go b/web/gitea_test.go new file mode 100644 index 0000000..ef41193 --- /dev/null +++ b/web/gitea_test.go @@ -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) + } +} diff --git a/web/server.go b/web/server.go index b652b35..cfa76b0 100644 --- a/web/server.go +++ b/web/server.go @@ -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, }) } diff --git a/web/static/style.css b/web/static/style.css index d0182ac..f5b61b5 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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); } diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl index 5f02f5e..b0ede3a 100644 --- a/web/templates/detail.tmpl +++ b/web/templates/detail.tmpl @@ -17,6 +17,10 @@ {{template "tasks-section" .}} {{end}} +{{if and .GiteaOn .Issues}} +{{template "issues-section" .}} +{{end}} +
diff --git a/web/templates/issues_section.tmpl b/web/templates/issues_section.tmpl new file mode 100644 index 0000000..7a1ccbf --- /dev/null +++ b/web/templates/issues_section.tmpl @@ -0,0 +1,45 @@ +{{define "issues-section"}} +
+

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

+ {{range .Issues}} +
+

+ {{.Repo}} + ↗ Gitea repo +

+ {{if .Error}}{{end}} + {{if .Open}} +
    + {{range .Open}} +
  • + #{{.Number}} + {{.Title}} + {{range .Labels}}{{.}}{{end}} + {{if .Milestone}}{{.Milestone}}{{end}} + {{range .Assignees}}@{{.}}{{end}} + {{if .UpdatedRel}}updated {{.UpdatedRel}}{{end}} +
  • + {{end}} +
+ {{else if not .Error}} +

No open issues.

+ {{end}} + {{if .ClosedRecent}} +
+ {{len .ClosedRecent}} closed in last 30 days +
    + {{range .ClosedRecent}} +
  • + #{{.Number}} + {{.Title}} + {{range .Labels}}{{.}}{{end}} + {{if .UpdatedRel}}{{.UpdatedRel}}{{end}} +
  • + {{end}} +
+
+ {{end}} +
+ {{end}} +
+{{end}}