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) Invalidate(key string) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() delete(c.rows, key) } 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() }