2c0f8fa257
Refs #1 - F1: canonical fingerprints (git identity by remote URL, log by basename, obsidian by relative vault path) so identical facts get identical ids across machines - F2: node_id (persisted in settings) + Hybrid Logical Clock in all observation "when" columns; removed time.Now() fact-time fallbacks - F3: stable TF-IDF tie-break sort (score desc, term asc) - F4: observations carry (node_id, hcl) locator; dedup ordered by hcl
248 lines
6.1 KiB
Go
248 lines
6.1 KiB
Go
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)
|