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/caldav"
|
||||||
"github.com/m/projax/db"
|
"github.com/m/projax/db"
|
||||||
|
"github.com/m/projax/gitea"
|
||||||
"github.com/m/projax/store"
|
"github.com/m/projax/store"
|
||||||
"github.com/m/projax/web"
|
"github.com/m/projax/web"
|
||||||
)
|
)
|
||||||
@@ -90,6 +91,18 @@ func main() {
|
|||||||
logger.Info("caldav: disabled — DAV_URL not set")
|
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{
|
httpServer := &http.Server{
|
||||||
Addr: listen,
|
Addr: listen,
|
||||||
Handler: srv.Routes(),
|
Handler: srv.Routes(),
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ env:
|
|||||||
- PROJAX_AUTO_MIGRATE=on
|
- PROJAX_AUTO_MIGRATE=on
|
||||||
- SUPABASE_URL=https://supa.flexsiebels.de
|
- SUPABASE_URL=https://supa.flexsiebels.de
|
||||||
- DAV_URL=https://dav.msbls.de/dav/calendars/m/
|
- DAV_URL=https://dav.msbls.de/dav/calendars/m/
|
||||||
|
- GITEA_URL=https://mgit.msbls.de
|
||||||
secrets:
|
secrets:
|
||||||
- PROJAX_DB_URL
|
- PROJAX_DB_URL
|
||||||
- SUPABASE_ANON_KEY
|
- SUPABASE_ANON_KEY
|
||||||
- DAV_USER
|
- DAV_USER
|
||||||
- DAV_PASSWORD
|
- 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.
|
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)
|
## 8. Open questions (post-PRD)
|
||||||
|
|
||||||
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
|
- **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
|
Logger *slog.Logger
|
||||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
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
|
// 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
|
pages[name] = t
|
||||||
}
|
}
|
||||||
// detail bundles the shared tasks-section partial so HTMX swaps and the
|
// detail bundles the shared tasks-section + issues-section partials so
|
||||||
// initial page render hit the same template definition.
|
// HTMX swaps and the initial page render hit the same template definitions.
|
||||||
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
||||||
"templates/layout.tmpl",
|
"templates/layout.tmpl",
|
||||||
"templates/detail.tmpl",
|
"templates/detail.tmpl",
|
||||||
"templates/tasks_section.tmpl",
|
"templates/tasks_section.tmpl",
|
||||||
|
"templates/issues_section.tmpl",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse detail: %w", err)
|
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 {
|
if err != nil {
|
||||||
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
|
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{
|
s.render(w, "detail", map[string]any{
|
||||||
"Title": it.Title,
|
"Title": it.Title,
|
||||||
"Item": it,
|
"Item": it,
|
||||||
"ParentOptions": parents,
|
"ParentOptions": parents,
|
||||||
"StatusOptions": []string{"active", "done", "archived"},
|
"StatusOptions": []string{"active", "done", "archived"},
|
||||||
"Tasks": tasks,
|
"Tasks": tasks,
|
||||||
"CalDAVOn": s.CalDAV != nil,
|
"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 .todo-create input[type="text"] { flex: 1; }
|
||||||
.tasks ul.done .summary { color: var(--muted); text-decoration: line-through; 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; }
|
.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" .}}
|
{{template "tasks-section" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .GiteaOn .Issues}}
|
||||||
|
{{template "issues-section" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></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