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:
mAi
2026-05-15 17:27:01 +02:00
parent 0e7f0f7b08
commit 1ffbfc6e69
12 changed files with 735 additions and 8 deletions

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