Files
projax/web/gitea.go
mAi 1ffbfc6e69 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).
2026-05-15 17:27:01 +02:00

217 lines
5.9 KiB
Go

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()
}