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).
129 lines
3.6 KiB
Go
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
|
|
}
|