Files
projax/gitea/issues.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

129 lines
3.6 KiB
Go

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
}