feat(phase 2.d gitea): read-only issue ingest on items with gitea-repo links
gitea package (new): minimal client mirroring caldav's structure
- client.go: token auth, 5s timeout, ErrNotFound
- issues.go: ListIssues(owner, repo, opts) hitting
/repos/{o}/{r}/issues?type=issues&state=…&since=…, ParseRepoRef,
RepoHTMLURL. PullRequest-flagged rows dropped server- and client-side.
- httptest stubs covering parse, 404, ParseRepoRef variants.
web wiring:
- Server.Gitea optional GiteaDeps (Client + in-memory 3-min TTL cache
keyed by owner/repo|state).
- detailIssues iterates every gitea-repo link, sums open issues, captures
last-30d closed (≤20) into a disclosure. Per-repo failures surface as
banner; one missing repo never blanks the section.
- relativeTime renders "Nm/h/d ago" / "yesterday" / fallback date.
Templates:
- issues_section.tmpl: per-repo block, header "Issues (n) + ↗ Gitea repo",
rows with #N · title · labels · milestone · assignees · updated.
Titles open in new tab.
- detail.tmpl: include the partial when Gitea is on and issues != nil.
- CSS: matches the Tasks section visual language.
main.go: GITEA_URL gates the integration (off when unset). GITEA_URL set
but GITEA_TOKEN missing → refuse to start.
deploy/dokploy.yaml: GITEA_URL env + GITEA_TOKEN secret added.
docs/design.md: new §6 mirroring §5's structure (link model, listing
semantics, caching, env contract, parked items).
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
68
gitea/client.go
Normal 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
128
gitea/issues.go
Normal 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
143
gitea/issues_test.go
Normal 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
216
web/gitea.go
Normal 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
66
web/gitea_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
web/templates/issues_section.tmpl
Normal file
45
web/templates/issues_section.tmpl
Normal 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}}
|
||||
Reference in New Issue
Block a user