package ingest import ( "encoding/json" "fmt" "os/exec" "time" ) type GiteaIngester struct { Version string } func NewGiteaIngester() *GiteaIngester { return &GiteaIngester{Version: "gitea-tea/v1"} } func (g *GiteaIngester) SourceID() string { return "gitea" } func (g *GiteaIngester) IngestAll() ([]*IngestResult, error) { var results []*IngestResult repos, err := g.fetchRepos() if err == nil { for _, r := range repos { if result := g.repoToResult(r); result != nil { results = append(results, result) } } } issues, err := g.fetchIssues() if err == nil { for _, i := range issues { if result := g.issueToResult(i); result != nil { results = append(results, result) } } } pulls, err := g.fetchPulls() if err == nil { for _, p := range pulls { if result := g.pullToResult(p); result != nil { results = append(results, result) } } } return results, nil } type teaRepo struct { Owner string `json:"owner"` Name string `json:"name"` Description string `json:"description"` Updated string `json:"updated"` } type teaIssue struct { Index string `json:"index"` Title string `json:"title"` State string `json:"state"` Owner string `json:"owner"` Repo string `json:"repo"` Labels string `json:"labels"` Author string `json:"author"` Updated string `json:"updated"` } type teaPull struct { Index string `json:"index"` Title string `json:"title"` State string `json:"state"` Owner string `json:"owner"` Repo string `json:"repo"` Updated string `json:"updated"` } func (g *GiteaIngester) teaJSON(args ...string) ([][]byte, error) { cmd := exec.Command("tea", append(args, "--output", "json")...) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("tea %s: %w", args[0], err) } var raw json.RawMessage if err := json.Unmarshal(output, &raw); err != nil { return nil, err } // Handle both array and object responses var arr []json.RawMessage if err := json.Unmarshal(raw, &arr); err == nil { bytes := make([][]byte, len(arr)) for i, a := range arr { bytes[i] = a } return bytes, nil } // Single object — wrap in array return [][]byte{raw}, nil } func (g *GiteaIngester) fetchRepos() ([]teaRepo, error) { items, err := g.teaJSON("repos", "list", "--limit", "100", "--fields", "owner,name,description,updated") if err != nil { return nil, err } var repos []teaRepo for _, item := range items { var r teaRepo if err := json.Unmarshal(item, &r); err == nil { repos = append(repos, r) } } return repos, nil } func (g *GiteaIngester) fetchIssues() ([]teaIssue, error) { items, err := g.teaJSON("issues", "list", "--state", "open", "--limit", "100", "--fields", "index,title,state,author,milestone,labels,owner,repo,updated") if err != nil { return nil, err } var issues []teaIssue for _, item := range items { var i teaIssue if err := json.Unmarshal(item, &i); err == nil { issues = append(issues, i) } } return issues, nil } func (g *GiteaIngester) fetchPulls() ([]teaPull, error) { items, err := g.teaJSON("pulls", "list", "--state", "open", "--limit", "100", "--fields", "index,title,state,owner,repo,updated") if err != nil { return nil, err } var pulls []teaPull for _, item := range items { var p teaPull if err := json.Unmarshal(item, &p); err == nil { pulls = append(pulls, p) } } return pulls, nil } // signalTime returns the item's last-activity time, or "" when unknown. An // empty value is an explicit unknown — downstream uses it as a missing signal, // never fabricates a "now" timestamp that would differ across nodes. func signalTime(updated string) string { if updated != "" { if _, err := time.Parse(time.RFC3339, updated); err == nil { return updated } } return "" } func (g *GiteaIngester) repoToResult(r teaRepo) *IngestResult { fullName := r.Owner + "/" + r.Name fp := Fingerprint([]byte("gitea:repo:" + fullName)) return &IngestResult{ Fingerprint: fp, SourceID: g.SourceID(), SourcePath: fullName, Project: r.Name, ContentType: "repo", Title: fullName, Summary: r.Description, Confidence: 0.9, IngesterVersion: g.Version, CreatedAt: signalTime(r.Updated), Provenance: map[string]any{ "type": "repository", "owner": r.Owner, "name": r.Name, "description": r.Description, }, } } func (g *GiteaIngester) issueToResult(i teaIssue) *IngestResult { title := fmt.Sprintf("#%s %s", i.Index, i.Title) fp := Fingerprint([]byte(fmt.Sprintf("gitea:issue:%s/%s:%s", i.Owner, i.Repo, i.Index))) summary := fmt.Sprintf("[%s/%s#%s] %s", i.Owner, i.Repo, i.Index, i.Title) if i.Labels != "" { summary += " [" + i.Labels + "]" } return &IngestResult{ Fingerprint: fp, SourceID: g.SourceID(), SourcePath: fmt.Sprintf("%s/%s#%s", i.Owner, i.Repo, i.Index), Project: i.Repo, ContentType: "issue", Title: title, Summary: summary, Confidence: 0.85, IngesterVersion: g.Version, CreatedAt: signalTime(i.Updated), Provenance: map[string]any{ "type": "issue", "owner": i.Owner, "repo": i.Repo, "index": i.Index, "state": i.State, "labels": i.Labels, }, } } func (g *GiteaIngester) pullToResult(p teaPull) *IngestResult { title := fmt.Sprintf("!%s %s", p.Index, p.Title) fp := Fingerprint([]byte(fmt.Sprintf("gitea:pull:%s/%s:%s", p.Owner, p.Repo, p.Index))) return &IngestResult{ Fingerprint: fp, SourceID: g.SourceID(), SourcePath: fmt.Sprintf("%s/%s!%s", p.Owner, p.Repo, p.Index), Project: p.Repo, ContentType: "pull", Title: title, Summary: fmt.Sprintf("[PR %s/%s] %s", p.Owner, p.Repo, p.Title), Confidence: 0.85, IngesterVersion: g.Version, CreatedAt: signalTime(p.Updated), Provenance: map[string]any{ "type": "pull_request", "owner": p.Owner, "repo": p.Repo, "index": p.Index, "state": p.State, }, } } func (g *GiteaIngester) Ingest(_ string) (*IngestResult, error) { return nil, fmt.Errorf("use IngestAll() for gitea") } var _ Ingester = (*GiteaIngester)(nil)