diff --git a/internal/cliapp/app.go b/internal/cliapp/app.go index 29285b08..541d28ae 100644 --- a/internal/cliapp/app.go +++ b/internal/cliapp/app.go @@ -183,6 +183,7 @@ func newReadFinder() (*finders.Finder, error) { PreflightRunner: readOnlyRunner, Config: cfg, Repos: reg, + GitHistory: git.CLI{}, }), nil } @@ -383,6 +384,7 @@ func New(options Options) *cli.Command { configCmd(), showCmd(), viewCmd(), + exportCmd(), newCmd(), rewriteCmd(), wipCmd(), diff --git a/internal/cliapp/export.go b/internal/cliapp/export.go new file mode 100644 index 00000000..3c31b496 --- /dev/null +++ b/internal/cliapp/export.go @@ -0,0 +1,147 @@ +package cliapp + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/presenters" + "github.com/networkteam/sdd/internal/query" + sddapp "github.com/networkteam/sdd/pkg/application" + "github.com/urfave/cli/v3" +) + +func exportCmd() *cli.Command { + return &cli.Command{ + Name: "export", + Usage: "EXPERIMENTAL: export the graph and its declared dependencies with derived status and heat as one JSON document (unstable shape)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "format", + Value: "json", + Usage: "Output format: json (the only one)", + }, + &cli.StringFlag{ + Name: "dependencies", + Value: string(query.ExportDependenciesReferenced), + Usage: "What the declared dependency closure contributes: none, referenced (entries the local graph cites, plus --hops upstream steps) or all (whole graphs)", + }, + &cli.IntFlag{ + Name: "hops", + Value: 1, + Usage: "Upstream steps (refs, closes, supersedes) followed from cited dependency entries with --dependencies referenced; 0 = only the cited ones", + }, + &cli.StringFlag{ + Name: "out", + Usage: "Write the document to this file (replaced atomically) instead of stdout", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if format := cmd.String("format"); format != "json" { + return fmt.Errorf("--format: unsupported value %q (only json)", format) + } + mode, err := query.ParseExportDependencies(cmd.String("dependencies")) + if err != nil { + return fmt.Errorf("--dependencies: %w", err) + } + hops := int(cmd.Int("hops")) + if hops < 0 { + return fmt.Errorf("--hops: must not be negative, got %d", hops) + } + if cmd.IsSet("hops") && mode != query.ExportDependenciesReferenced { + return fmt.Errorf("--hops applies only to --dependencies referenced") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + var dependencyIDs []string + if mode != query.ExportDependenciesNone && cfg != nil { + if dependencyIDs, err = dependencyClosure(ctx, cmd.ErrWriter, cfg); err != nil { + return err + } + } + + dir, err := resolveGraphDir(cmd) + if err != nil { + return err + } + f, err := newReadFinder() + if err != nil { + return err + } + g, err := f.CurrentGraph(dir) + if err != nil { + return err + } + result, err := f.OnGraph(g).Export(ctx, query.ExportQuery{ + Dependencies: mode, DependencyIDs: dependencyIDs, Hops: hops, Now: time.Now(), + }) + if err != nil { + return err + } + if out := cmd.String("out"); out != "" { + return writeFileAtomic(out, func(w io.Writer) error { return presenters.RenderExportJSON(w, result) }) + } + return presenters.RenderExportJSON(cmd.Writer, result) + }, + } +} + +// dependencyClosure resolves the repo's declared dependencies transitively: +// each reached repo's cache is freshened, then its own declarations are read +// from its committed config with the reader `sdd serve` composes dependency +// projects from. +func dependencyClosure(ctx context.Context, errWriter io.Writer, cfg *model.PerRepoConfig) ([]string, error) { + reg, _, err := defaultRepos() + if err != nil { + return nil, err + } + return model.DependencyClosure(cfg.RepoID, cfg.Dependencies, func(repoID string) ([]string, error) { + if err := freshenRepoCaches(ctx, errWriter, []string{repoID}); err != nil { + return nil, err + } + cacheDir, err := reg.CacheDir(repoID) + if err != nil { + return nil, err + } + dependencyCfg, err := sddapp.ReadProjectConfigFS(os.DirFS(cacheDir)) + if errors.Is(err, sddapp.ErrNotAnSDDProject) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading config of dependency %s: %w", repoID, err) + } + return dependencyCfg.Dependencies, nil + }) +} + +// writeFileAtomic renders into a temp file beside path and renames it over +// path, so a reader never sees a partial document. The file keeps the temp +// file's owner-only mode: the document carries entry bodies and attachment +// text. +func writeFileAtomic(path string, render func(io.Writer) error) (err error) { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + if err = render(tmp); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/internal/cliapp/export_test.go b/internal/cliapp/export_test.go new file mode 100644 index 00000000..a9beca75 --- /dev/null +++ b/internal/cliapp/export_test.go @@ -0,0 +1,332 @@ +package cliapp_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type exportEntry struct { + ID string `json:"id"` + FullID string `json:"full_id"` + ShortID string `json:"short_id"` + Time string `json:"time"` + Status string `json:"status"` + StatusBy string `json:"status_by"` + ClosedBy []string `json:"closed_by"` + Closes []string `json:"closes"` + Summary string `json:"summary"` + Body string `json:"body"` + Heat float64 `json:"heat"` + InDegree int `json:"in_degree"` + Refs []struct { + ID, Kind, Desc string + } `json:"refs"` + Attachments []struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Truncated bool `json:"truncated"` + } `json:"attachments"` +} + +type exportDoc struct { + Experimental bool `json:"experimental"` + Embedded []exportEntry `json:"embedded"` + Repos []struct { + RepoID string `json:"repo_id"` + Local bool `json:"local"` + Revision string `json:"revision"` + Selection *struct { + Mode string `json:"mode"` + Hops *int `json:"hops"` + TotalEntries int `json:"total_entries"` + } `json:"selection"` + Entries []exportEntry `json:"entries"` + WIP []struct { + Entry string `json:"entry"` + Participant string `json:"participant"` + } `json:"wip"` + } `json:"repos"` +} + +// runGit runs git in dir. +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false"}, args...)...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// TestCLIExportJSON pins the export's derived attributes against a git repo: +// identity, status, refs, heat, attachments, WIP, and the HEAD revision, which +// stays empty before the first commit. +func TestCLIExportJSON(t *testing.T) { + f := newAppFixture(t) + const closerID = "20260914-030000-d-tac-dec" + git := func(args ...string) string { + t.Helper() + return runGit(t, f.root, args...) + } + git("init", "-q", "-b", "main") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + f.write(t, "decisions/2026/09/14-030000-d-tac-dec.md", "---\ntype: decision\nkind: directive\nintent: pending\nlayer: tactical\nconfidence: high\nparticipants: [Local Author]\nrefs:\n - id: "+appEntryID+"\n kind: addresses\n desc: answers the gap\ncloses: ["+appEntryID+"]\n---\n\nThe decision.\n") + f.write(t, "decisions/2026/09/14-030000-d-tac-dec/notes.md", strings.Repeat("A note line.\n", 2000)) + f.write(t, "decisions/wip/20260914-050000-local-author.md", "---\nentry: "+closerID+"\nparticipant: Local Author\n---\n\nFollowing up.\n") + + export := func() exportDoc { + t.Helper() + result := f.run(t, "export", "--format", "json") + if result.err != nil { + t.Fatalf("export: %v\n%s", result.err, result.stderr) + } + var doc exportDoc + if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { + t.Fatalf("export emitted invalid JSON: %v\n%s", err, result.stdout) + } + if !doc.Experimental || len(doc.Repos) != 1 { + t.Fatalf("experimental = %v, repos = %d; want true, 1", doc.Experimental, len(doc.Repos)) + } + return doc + } + + if rev := export().Repos[0].Revision; rev != "" { + t.Errorf("revision before the first commit = %q, want none", rev) + } + git("add", "decisions") + git("commit", "-q", "-m", "capture") + head := git("rev-parse", "HEAD") + + doc := export() + repo := doc.Repos[0] + if repo.RepoID != "example.test/cli" || !repo.Local || repo.Revision != head { + t.Errorf("repo = %s local=%v revision=%s; want example.test/cli local=true revision=%s", repo.RepoID, repo.Local, repo.Revision, head) + } + if len(repo.WIP) != 1 || repo.WIP[0].Entry != closerID || repo.WIP[0].Participant != "Local Author" { + t.Errorf("wip = %+v, want one marker on %s", repo.WIP, closerID) + } + + if len(doc.Embedded) == 0 { + t.Error("export omitted the embedded base entries") + } + byID := map[string]int{} + for i, e := range repo.Entries { + byID[e.ID] = i + } + for _, e := range doc.Embedded { + if _, ok := byID[e.ID]; ok { + t.Errorf("embedded entry %s also listed under the local repo", e.ID) + } + } + + signal := repo.Entries[byID[appEntryID]] + if signal.ShortID != "s-tac-cli" || signal.Time != "2026-09-14T02:00:00" || signal.Summary != appSummary { + t.Errorf("signal identity = %s %s %q", signal.ShortID, signal.Time, signal.Summary) + } + if signal.Status != "closed-by" || signal.StatusBy != closerID || len(signal.ClosedBy) != 1 || signal.ClosedBy[0] != closerID { + t.Errorf("signal status = %s by %s closed_by %v; want closed-by %s", signal.Status, signal.StatusBy, signal.ClosedBy, closerID) + } + if signal.InDegree != 1 || signal.Heat <= 0 { + t.Errorf("signal in_degree = %d heat = %v; want 1 and positive heat", signal.InDegree, signal.Heat) + } + + closer := repo.Entries[byID[closerID]] + if closer.Status != "active" || len(closer.Closes) != 1 || len(closer.Refs) != 1 || + closer.Refs[0].ID != appEntryID || closer.Refs[0].Kind != "addresses" || closer.Refs[0].Desc != "answers the gap" { + t.Errorf("decision = status %s closes %v refs %+v", closer.Status, closer.Closes, closer.Refs) + } + if len(closer.Attachments) != 1 { + t.Fatalf("attachments = %+v, want notes.md", closer.Attachments) + } + a := closer.Attachments[0] + if a.Name != "notes.md" || a.Size != 26000 || !strings.HasPrefix(a.ContentType, "text/markdown") || + !a.Truncated || len(a.Content) == 0 || len(a.Content) > 16*1024 { + t.Errorf("attachment = %s size %d type %s truncated %v content %d bytes", a.Name, a.Size, a.ContentType, a.Truncated, len(a.Content)) + } + + if bad := f.run(t, "export", "--format", "text"); bad.err == nil || bad.stdout != "" { + t.Errorf("export --format text = %v with output %q, want an error and no result", bad.err, bad.stdout) + } +} + +// newDependencyRepo commits a graph declaring repoID (and deps) in a fresh +// repository and connects it, so the export's cache freshening clones it. +func newDependencyRepo(t *testing.T, f *appFixture, repoID string, deps []string, entries map[string]string) { + t.Helper() + dir := t.TempDir() + cfg := "repo_id: " + repoID + "\n" + if len(deps) > 0 { + cfg += "dependencies: [" + strings.Join(deps, ", ") + "]\n" + } + files := map[string]string{".sdd/config.yaml": cfg} + for rel, content := range entries { + files[".sdd/graph/"+rel] = content + } + for rel, content := range files { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + runGit(t, dir, "init", "-q", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-q", "-m", "graph") + + config, err := os.ReadFile(filepath.Join(f.root, "config/sdd/config.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(config), "repos:") { + config = append(config, "repos:\n"...) + } + config = append(config, " - repo_id: "+repoID+"\n clone_url: "+dir+"\n"...) + f.write(t, "config/sdd/config.yaml", string(config)) +} + +func testEntry(frontmatter string) string { + return "---\n" + frontmatter + "layer: tactical\nconfidence: medium\nparticipants: [Local Author]\n---\n\nBody.\n" +} + +// TestCLIExportDependencySelection walks the declared closure local → dep → +// deep: referenced mode starts from what the local graph cites and follows +// refs and supersedes upstream, crossing into deep only within the hop budget. +// The local graph overrides a base entry the dependency still cites, so the +// original must stay listed among the embedded entries. +func TestCLIExportDependencySelection(t *testing.T) { + f := newAppFixture(t) + const ( + cited = "20260901-100000-d-tac-aaa" + grounding = "20260901-090000-s-tac-bbb" + superseded = "20260901-080000-s-tac-ccc" + uncited = "20260901-070000-s-tac-ddd" + deepEntry = "20260801-100000-s-tac-xxx" + baseEntry = "20260703-094500-d-prc-cap" + overridden = "20260812-180000-s-prc-typ" + ) + newDependencyRepo(t, f, "example.test/deep", nil, map[string]string{ + "2026/08/01-100000-s-tac-xxx.md": testEntry("type: signal\nkind: fact\n"), + }) + newDependencyRepo(t, f, "example.test/dep", []string{"example.test/deep"}, map[string]string{ + "2026/09/01-100000-d-tac-aaa.md": testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: " + grounding + ", kind: grounded-in}\n - {id: " + baseEntry + ", kind: related}\n - {id: " + overridden + ", kind: related}\nsupersedes: [" + superseded + "]\n"), + "2026/09/01-090000-s-tac-bbb.md": testEntry("type: signal\nkind: gap\nrefs:\n - {id: 'example.test/deep:" + deepEntry + "', kind: grounded-in}\n"), + "2026/09/01-080000-s-tac-ccc.md": testEntry("type: signal\nkind: gap\n"), + "2026/09/01-070000-s-tac-ddd.md": testEntry("type: signal\nkind: gap\n"), + }) + f.write(t, ".sdd/config.yaml", "repo_id: example.test/cli\ngraph_dir: decisions\ndefault_branch: main\ndependencies: [example.test/dep]\n") + f.write(t, "decisions/2026/08/12-180000-s-prc-typ.md", "---\ntype: signal\nkind: fact\nlayer: process\nconfidence: high\nparticipants: [Local Author]\n---\n\nLocal override.\n") + f.write(t, "decisions/2026/09/14-030000-d-tac-loc.md", testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: 'example.test/dep:"+cited+"', kind: grounded-in}\n")) + + export := func(args ...string) exportDoc { + t.Helper() + result := f.run(t, append([]string{"export"}, args...)...) + if result.err != nil { + t.Fatalf("export %v: %v\n%s", args, result.err, result.stderr) + } + var doc exportDoc + if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { + t.Fatalf("export %v emitted invalid JSON: %v", args, err) + } + return doc + } + type repoWant struct { + repoID string + entries []string + } + for _, tt := range []struct { + name string + args []string + mode string + hops int + repos []repoWant + }{ + {name: "none", args: []string{"--dependencies", "none"}}, + {name: "referenced cited only", args: []string{"--hops", "0"}, mode: "referenced", hops: 0, repos: []repoWant{ + {"example.test/dep", []string{cited}}, {"example.test/deep", nil}, + }}, + {name: "referenced default one hop", mode: "referenced", hops: 1, repos: []repoWant{ + {"example.test/dep", []string{superseded, grounding, cited}}, {"example.test/deep", nil}, + }}, + {name: "referenced crosses into deep", args: []string{"--hops", "2"}, mode: "referenced", hops: 2, repos: []repoWant{ + {"example.test/dep", []string{superseded, grounding, cited}}, {"example.test/deep", []string{deepEntry}}, + }}, + {name: "all", args: []string{"--dependencies", "all"}, mode: "all", repos: []repoWant{ + {"example.test/dep", []string{uncited, superseded, grounding, cited}}, {"example.test/deep", []string{deepEntry}}, + }}, + } { + t.Run(tt.name, func(t *testing.T) { + doc := export(tt.args...) + if len(doc.Repos) != 1+len(tt.repos) || doc.Repos[0].RepoID != "example.test/cli" || doc.Repos[0].Selection != nil { + t.Fatalf("repos = %d, first %s selection %v; want local plus %d", len(doc.Repos), doc.Repos[0].RepoID, doc.Repos[0].Selection, len(tt.repos)) + } + for i, want := range tt.repos { + repo := doc.Repos[i+1] + var got []string + for _, e := range repo.Entries { + got = append(got, e.ID) + if e.FullID != want.repoID+":"+e.ID { + t.Errorf("%s entry full_id = %q", want.repoID, e.FullID) + } + } + if repo.RepoID != want.repoID || strings.Join(got, ",") != strings.Join(want.entries, ",") { + t.Errorf("repo %d = %s %v, want %s %v", i+1, repo.RepoID, got, want.repoID, want.entries) + } + sel := repo.Selection + wantTotal := map[string]int{"example.test/dep": 4, "example.test/deep": 1}[want.repoID] + if sel == nil || sel.Mode != tt.mode || sel.TotalEntries != wantTotal || (sel.Hops == nil) != (tt.mode != "referenced") || + (sel.Hops != nil && *sel.Hops != tt.hops) { + t.Errorf("%s selection = %+v, want mode %s hops %d total %d", want.repoID, sel, tt.mode, tt.hops, wantTotal) + } + } + found := false + var original *exportEntry + for i, e := range doc.Embedded { + found = found || e.ID == baseEntry + if e.ID == overridden { + original = &doc.Embedded[i] + } + } + if !found { + t.Errorf("base entry %s cited from a dependency is missing from the embedded list", baseEntry) + } + if tt.mode != "" && (original == nil || original.Body == "" || original.Body == "Local override.") { + t.Errorf("base entry %s overridden locally but cited from a dependency: embedded = %+v, want the original", overridden, original) + } + }) + } + + out := filepath.Join(f.root, "export.json") + if err := os.WriteFile(out, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + if result := f.run(t, "export", "--out", out); result.err != nil || result.stdout != "" { + t.Fatalf("export --out = %v with stdout %q", result.err, result.stdout) + } + if info, err := os.Stat(out); err != nil || info.Mode().Perm()&0o077 != 0 { + t.Errorf("--out file mode = %v (%v), want owner-only", info.Mode().Perm(), err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + var doc exportDoc + if err := json.Unmarshal(data, &doc); err != nil || len(doc.Repos) != 3 { + t.Errorf("--out file holds %d repos (%v), want 3", len(doc.Repos), err) + } + if bad := f.run(t, "export", "--dependencies", "all", "--hops", "2"); bad.err == nil { + t.Error("--hops with --dependencies all was accepted") + } +} diff --git a/internal/finders/export.go b/internal/finders/export.go new file mode 100644 index 00000000..52074532 --- /dev/null +++ b/internal/finders/export.go @@ -0,0 +1,215 @@ +package finders + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/query" + "github.com/networkteam/sdd/internal/truncate" +) + +// GitHistory is the git surface the graph export reads provenance from. +type GitHistory interface { + // InWorkTree reports whether dir lies inside a git work tree. + InWorkTree(ctx context.Context, dir string) bool + // HeadRevision returns the commit hash HEAD points at, "" before the + // first commit. + HeadRevision(ctx context.Context, dir string) (string, error) +} + +// attachmentSniffBytes is how much of a non-text attachment is read to +// detect its content type. +const attachmentSniffBytes = 512 + +// attachmentTypes pins the content type of the attachment formats graphs +// commonly carry, so the export does not depend on the host's MIME tables. +var attachmentTypes = map[string]string{ + ".md": "text/markdown; charset=utf-8", + ".markdown": "text/markdown; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".log": "text/plain; charset=utf-8", + ".json": "application/json", +} + +// Export assembles the held graph and its dependency repos into one document +// carrying each entry with the attributes the engine derives for it in its +// owning graph (see query.ExportQuery). +func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query.ExportResult, error) { + if gf.graph == nil { + return nil, fmt.Errorf("export: graph is required") + } + repoID, err := gf.finder.localRepoID() + if err != nil { + return nil, err + } + x := exporter{now: q.Now} + if x.decay, err = model.DecayByName(model.DefaultDecayName); err != nil { + return nil, err + } + + local, err := gf.exportRepo(ctx, x, repoID, nil) + if err != nil { + return nil, err + } + local.Local = true + result := &query.ExportResult{GeneratedAt: q.Now, Repos: []query.ExportRepo{local}} + embedded := map[string]bool{} + x.addEmbedded(result, embedded, gf.graph) + if q.Dependencies == query.ExportDependenciesNone { + return result, nil + } + + var cited map[string]map[string]bool + if q.Dependencies == query.ExportDependenciesReferenced { + if cited, err = model.CitedAcross(gf.graph, q.DependencyIDs, q.Hops); err != nil { + return nil, err + } + } + for _, id := range q.DependencyIDs { + member, err := gf.graph.MemberGraph(id) + if err != nil { + return nil, fmt.Errorf("loading graph for %s: %w", id, err) + } + if member == nil { + result.Repos = append(result.Repos, query.ExportRepo{RepoID: id, Unavailable: true}) + continue + } + var selected map[string]bool + if cited != nil { + selected = cited[id] + if selected == nil { + selected = map[string]bool{} + } + } + repo, err := gf.finder.OnGraph(member).exportRepo(ctx, x, id, selected) + if err != nil { + return nil, err + } + repo.Selection = &query.ExportSelection{Mode: q.Dependencies, Hops: q.Hops, TotalEntries: ownEntries(member)} + result.Repos = append(result.Repos, repo) + x.addEmbedded(result, embedded, member) + } + return result, nil +} + +// exporter holds what every exported entry is derived against. +type exporter struct { + decay model.DecayFunc + now time.Time +} + +// addEmbedded appends the base entries g carries that are not listed yet, +// derived in g. Collecting them from every exported graph keeps a base entry +// the local graph overrides: a dependency that does not override it still +// references the original. +func (x exporter) addEmbedded(result *query.ExportResult, listed map[string]bool, g *model.Graph) { + for _, e := range g.Entries { + if e.Embedded && !listed[e.ID] { + listed[e.ID] = true + result.Embedded = append(result.Embedded, x.entry(g, e, nil)) + } + } +} + +// exportRepo exports the held graph's own entries (embedded base entries are +// exported once, outside any repo), restricted to selected when non-nil. +func (gf *GraphFinder) exportRepo(ctx context.Context, x exporter, repoID string, selected map[string]bool) (query.ExportRepo, error) { + g := gf.graph + keep := func(id string) bool { return selected == nil || selected[id] } + markers, err := gf.WIPMarkers() + if err != nil { + return query.ExportRepo{}, err + } + repo := query.ExportRepo{RepoID: repoID, LoadIssues: g.LoadIssues} + for _, m := range markers { + if keep(m.Entry) { + repo.WIP = append(repo.WIP, m) + } + } + + if h, dir := gf.finder.gitHistory, g.GraphDir(); h != nil && dir != "" && h.InWorkTree(ctx, dir) { + if repo.Revision, err = h.HeadRevision(ctx, dir); err != nil { + return query.ExportRepo{}, err + } + } + + for _, e := range g.Entries { + if e.Embedded || !keep(e.ID) { + continue + } + attachments, err := gf.exportAttachments(e) + if err != nil { + return query.ExportRepo{}, err + } + repo.Entries = append(repo.Entries, x.entry(g, e, attachments)) + } + return repo, nil +} + +// entry derives one entry's exported attributes in its owning graph g. +func (x exporter) entry(g *model.Graph, e *model.Entry, attachments []query.ExportAttachment) query.ExportEntry { + entry := query.ExportEntry{ + Entry: e, + Status: g.DerivedStatus(e), + ClosedBy: g.ClosedBy[e.ID], + SupersededBy: g.SupersededBy[e.ID], + Topics: g.EffectiveTopics(e), + Heat: model.HeatScore(g, e, x.decay, x.now), + InDegree: int(model.InDegreeScore(g, e)), + Attachments: attachments, + } + if key, ok := g.DisplayID(e.ID); ok && key != e.ID { + entry.FullID = key + } + return entry +} + +// ownEntries counts a graph's entries apart from the embedded base entries. +func ownEntries(g *model.Graph) int { + n := 0 + for _, e := range g.Entries { + if !e.Embedded { + n++ + } + } + return n +} + +// exportAttachments describes an entry's attachments through the shared +// accessor, inlining Markdown and plain-text content up to the export limit. +func (gf *GraphFinder) exportAttachments(e *model.Entry) ([]query.ExportAttachment, error) { + var out []query.ExportAttachment + for _, rel := range e.Attachments { + name := filepath.Base(rel) + head, err := gf.ReadAttachment(query.ReadAttachmentQuery{EntryID: e.ID, Name: name, MaxBytes: attachmentSniffBytes}) + if err != nil { + return nil, err + } + a := query.ExportAttachment{Name: name, Size: head.TotalBytes, ContentType: attachmentContentType(name, head.Content)} + if strings.HasPrefix(a.ContentType, "text/markdown") || strings.HasPrefix(a.ContentType, "text/plain") { + // Read past the limit by one rune so the cut can tell a file that + // ends exactly at the limit from a longer one. + page, err := gf.ReadAttachment(query.ReadAttachmentQuery{EntryID: e.ID, Name: name, MaxBytes: query.ExportAttachmentTextLimit + utf8.UTFMax}) + if err != nil { + return nil, err + } + text := truncate.Bytes(page.Content, query.ExportAttachmentTextLimit, "") + a.Content, a.Truncated = text.Text, page.More || !text.Cut.Clean() + } + out = append(out, a) + } + return out, nil +} + +func attachmentContentType(name, head string) string { + if t, ok := attachmentTypes[strings.ToLower(filepath.Ext(name))]; ok { + return t + } + return http.DetectContentType([]byte(head)) +} diff --git a/internal/finders/finder.go b/internal/finders/finder.go index 7c9d7ec5..5c62cc7d 100644 --- a/internal/finders/finder.go +++ b/internal/finders/finder.go @@ -26,6 +26,7 @@ type Finder struct { writingGuideRunner llm.Runner cfg *model.PerRepoConfig gitSyncer GitSyncer + gitHistory GitHistory repos *repos.Registry procedureRegistry *engine.Registry } @@ -38,6 +39,9 @@ type Options struct { WritingGuideRunner llm.Runner Config *model.PerRepoConfig GitSyncer GitSyncer + // GitHistory supplies revision and arrival provenance for the graph + // export. Nil exports without it. + GitHistory GitHistory // Repos is the pure read surface over the connected repos — the only // cross-repo capability a finder holds (no clone, no pull). Nil means no // connected-repos support: cross-repo refs stay unresolved. @@ -55,6 +59,7 @@ func New(opts Options) *Finder { writingGuideRunner: opts.WritingGuideRunner, cfg: opts.Config, gitSyncer: opts.GitSyncer, + gitHistory: opts.GitHistory, repos: opts.Repos, procedureRegistry: opts.ProcedureRegistry, } @@ -87,3 +92,12 @@ func (f *Finder) declaredDependencies() ([]string, error) { } return f.cfg.Dependencies, nil } + +// localRepoID returns the repo's canonical identity from config; "" means the +// repo declares none (local-only). +func (f *Finder) localRepoID() (string, error) { + if f.cfg == nil { + return "", fmt.Errorf("repo ID unavailable: no per-repo config") + } + return f.cfg.RepoID, nil +} diff --git a/internal/git/history.go b/internal/git/history.go new file mode 100644 index 00000000..cd105c9c --- /dev/null +++ b/internal/git/history.go @@ -0,0 +1,29 @@ +package git + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// The finders.GitHistory surface: read-only provenance for the graph export. + +// InWorkTree reports whether dir lies inside a git work tree. +func (CLI) InWorkTree(ctx context.Context, dir string) bool { + out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--is-inside-work-tree").Output() + return err == nil && strings.TrimSpace(string(out)) == "true" +} + +// HeadRevision returns the commit hash HEAD points at in dir's repository, or +// "" when HEAD has no commit yet. +func (CLI) HeadRevision(ctx context.Context, dir string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--verify", "--quiet", "HEAD").Output() + if err != nil { + if errExitCode(err) == 1 && len(out) == 0 { + return "", nil + } + return "", fmt.Errorf("git rev-parse HEAD: %w", err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/model/crossrepo_selection.go b/internal/model/crossrepo_selection.go new file mode 100644 index 00000000..5a64231c --- /dev/null +++ b/internal/model/crossrepo_selection.go @@ -0,0 +1,99 @@ +package model + +// DependencyClosure walks declared dependencies breadth-first from direct and +// returns every reached repo ID once, in first-reached order, never root +// itself. declared returns a reached repo's own declarations; a repo it cannot +// resolve returns none, so it stays listed but nothing behind it is reached. +func DependencyClosure(root string, direct []string, declared func(repoID string) ([]string, error)) ([]string, error) { + seen := map[string]bool{root: true} + var closure []string + queue := append([]string(nil), direct...) + for len(queue) > 0 { + repoID := queue[0] + queue = queue[1:] + if seen[repoID] { + continue + } + seen[repoID] = true + closure = append(closure, repoID) + next, err := declared(repoID) + if err != nil { + return nil, err + } + queue = append(queue, next...) + } + return closure, nil +} + +// CitedAcross selects the entries of other repos that the local graph cites: +// the targets of its cross-repo refs, closes and supersedes, then up to hops +// further upstream steps along the same fields from each selected entry — a +// bare ID within that entry's repo, a cross-repo ID into the repo it names. +// Only repos in scope are entered, and embedded entries are never selected +// (no repo owns them). The result maps repo ID to the selected entry IDs. +func CitedAcross(local *Graph, scope []string, hops int) (map[string]map[string]bool, error) { + inScope := make(map[string]bool, len(scope)) + for _, repoID := range scope { + inScope[repoID] = true + } + type step struct { + repoID string + entry *Entry + depth int + } + selected := map[string]map[string]bool{} + var queue []step + add := func(repoID, id string, depth int) error { + if !inScope[repoID] || selected[repoID][id] { + return nil + } + member, err := local.MemberGraph(repoID) + if err != nil || member == nil { + return err + } + e, ok := member.ByID[id] + if !ok || e.Embedded { + return nil + } + if selected[repoID] == nil { + selected[repoID] = map[string]bool{} + } + selected[repoID][id] = true + queue = append(queue, step{repoID: repoID, entry: e, depth: depth}) + return nil + } + + for _, e := range local.Entries { + for _, id := range upstreamIDs(e) { + if repoID, entryID, ok := SplitCrossRepoID(id); ok { + if err := add(repoID, entryID, 0); err != nil { + return nil, err + } + } + } + } + for len(queue) > 0 { + s := queue[0] + queue = queue[1:] + if s.depth >= hops { + continue + } + for _, id := range upstreamIDs(s.entry) { + repoID, entryID := s.repoID, id + if r, eid, ok := SplitCrossRepoID(id); ok { + repoID, entryID = r, eid + } + if err := add(repoID, entryID, s.depth+1); err != nil { + return nil, err + } + } + } + return selected, nil +} + +// upstreamIDs lists the IDs an entry points at: refs, closes, supersedes. +func upstreamIDs(e *Entry) []string { + ids := RefIDs(e.Refs) + ids = append(ids, e.Closes...) + return append(ids, e.Supersedes...) +} diff --git a/internal/model/crossrepo_selection_test.go b/internal/model/crossrepo_selection_test.go new file mode 100644 index 00000000..f6d5c824 --- /dev/null +++ b/internal/model/crossrepo_selection_test.go @@ -0,0 +1,139 @@ +package model + +import ( + "errors" + "maps" + "slices" + "strings" + "testing" +) + +func TestDependencyClosure(t *testing.T) { + declarations := map[string][]string{ + "a": {"c", "root"}, + "b": {"c", "d", "x"}, + "c": {"a"}, + "d": nil, + "e": {"f"}, + } + declared := func(repoID string) ([]string, error) { + if repoID == "broken" { + return nil, errors.New("unreadable config") + } + // "x" stands for a repo that cannot be resolved: it declares nothing. + return declarations[repoID], nil + } + + for _, tt := range []struct { + name string + direct []string + want []string + }{ + {name: "breadth-first, each once, never root", direct: []string{"a", "b"}, want: []string{"a", "b", "c", "d", "x"}}, + {name: "duplicate direct dependencies", direct: []string{"d", "d"}, want: []string{"d"}}, + {name: "no dependencies", direct: nil, want: nil}, + {name: "transitive chain", direct: []string{"e"}, want: []string{"e", "f"}}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := DependencyClosure("root", tt.direct, declared) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("closure = %v, want %v", got, tt.want) + } + }) + } + + if _, err := DependencyClosure("root", []string{"d", "broken"}, declared); err == nil { + t.Error("a declaration read error was swallowed") + } +} + +func TestCitedAcross(t *testing.T) { + const ( + dep = "example.com/team/dep" + deep = "example.com/team/deep" + other = "example.com/team/other" + gone = "example.com/team/gone" + bad = "example.com/team/bad" + ) + base := entry("20260301-090000-s-prc-bas", withEmbedded()) + local := NewGraph([]*Entry{ + entry("20260410-100000-d-tac-loc", withRefs( + dep+":20260401-100000-d-cpt-aaa", + dep+":20260301-090000-s-prc-bas", // a base entry: no repo owns it + other+":20260401-100000-s-cpt-oth", // outside the scope + gone+":20260401-100000-s-cpt-gon", // in scope, not resolvable + "20260409-100000-s-tac-bar", // bare: a local entry + )), + entry("20260409-100000-s-tac-bar"), + base, + }) + depGraph := NewGraph([]*Entry{ + entry("20260401-100000-d-cpt-aaa", + withRefs("20260331-100000-s-cpt-bbb", deep+":20260201-100000-s-cpt-xxx"), + withSupersedes("20260330-100000-d-cpt-ccc")), + entry("20260331-100000-s-cpt-bbb", withCloses("20260329-100000-s-cpt-ddd")), + entry("20260330-100000-d-cpt-ccc"), + entry("20260329-100000-s-cpt-ddd"), + entry("20260328-100000-s-cpt-unc"), + base, + }) + deepGraph := NewGraph([]*Entry{entry("20260201-100000-s-cpt-xxx")}) + NewMultiGraph(local, []string{dep}, func(repoID string) (*Graph, error) { + switch repoID { + case dep: + return depGraph, nil + case deep: + return deepGraph, nil + case other: + return NewGraph([]*Entry{entry("20260401-100000-s-cpt-oth")}), nil + } + return nil, nil + }) + scope := []string{dep, deep, gone} + + flatten := func(selected map[string]map[string]bool) string { + var out []string + for _, repoID := range slices.Sorted(maps.Keys(selected)) { + for _, id := range slices.Sorted(maps.Keys(selected[repoID])) { + out = append(out, repoID+":"+id) + } + } + return strings.Join(out, " ") + } + for _, tt := range []struct { + name string + hops int + want []string + }{ + {name: "cited only", hops: 0, want: []string{dep + ":20260401-100000-d-cpt-aaa"}}, + {name: "one hop follows refs, supersedes and crossings", hops: 1, want: []string{ + deep + ":20260201-100000-s-cpt-xxx", + dep + ":20260330-100000-d-cpt-ccc", dep + ":20260331-100000-s-cpt-bbb", dep + ":20260401-100000-d-cpt-aaa", + }}, + {name: "two hops follow closes", hops: 2, want: []string{ + deep + ":20260201-100000-s-cpt-xxx", + dep + ":20260329-100000-s-cpt-ddd", dep + ":20260330-100000-d-cpt-ccc", dep + ":20260331-100000-s-cpt-bbb", dep + ":20260401-100000-d-cpt-aaa", + }}, + } { + t.Run(tt.name, func(t *testing.T) { + selected, err := CitedAcross(local, scope, tt.hops) + if err != nil { + t.Fatal(err) + } + if got := flatten(selected); got != strings.Join(tt.want, " ") { + t.Errorf("selected = %s\nwant %s", got, strings.Join(tt.want, " ")) + } + }) + } + + t.Run("a member that fails to load is an error", func(t *testing.T) { + broken := NewGraph([]*Entry{entry("20260410-100000-d-tac-brk", withRefs(bad+":20260401-100000-s-cpt-bad"))}) + NewMultiGraph(broken, []string{bad}, func(string) (*Graph, error) { return nil, errors.New("broken cache") }) + if _, err := CitedAcross(broken, []string{bad}, 1); err == nil { + t.Error("the load error was swallowed") + } + }) +} diff --git a/internal/presenters/export.go b/internal/presenters/export.go new file mode 100644 index 00000000..3d56b401 --- /dev/null +++ b/internal/presenters/export.go @@ -0,0 +1,227 @@ +package presenters + +import ( + "encoding/json" + "io" + "time" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/query" +) + +// wallClockLayout renders the timestamp an entry or WIP ID carries: the +// capturer's local wall clock, which records no zone, so none is claimed. +const wallClockLayout = "2006-01-02T15:04:05" + +type exportJSON struct { + Experimental bool `json:"experimental"` + GeneratedAt string `json:"generated_at"` + Repos []exportRepoJSON `json:"repos"` + // Embedded lists the base entries once; an ID from any repo that names + // one resolves here (query.ExportResult). + Embedded []exportEntryJSON `json:"embedded"` +} + +type exportRepoJSON struct { + RepoID string `json:"repo_id"` + Local bool `json:"local"` + Unavailable bool `json:"unavailable,omitempty"` + Revision string `json:"revision,omitempty"` + Selection *selectionJSON `json:"selection,omitempty"` + Entries []exportEntryJSON `json:"entries"` + WIP []exportWIPJSON `json:"wip"` + LoadIssues []loadIssueJSON `json:"load_issues,omitempty"` +} + +type exportEntryJSON struct { + ID string `json:"id"` + FullID string `json:"full_id,omitempty"` + ShortID string `json:"short_id,omitempty"` + Type model.EntryType `json:"type"` + Kind model.Kind `json:"kind,omitempty"` + Layer model.Layer `json:"layer"` + Confidence string `json:"confidence,omitempty"` + Intent model.Intent `json:"intent,omitempty"` + Participants []string `json:"participants,omitempty"` + Topics []string `json:"topics,omitempty"` + Time string `json:"time"` + Status model.StatusKind `json:"status,omitempty"` + StatusBy string `json:"status_by,omitempty"` + ClosedBy []string `json:"closed_by,omitempty"` + SupersededBy []string `json:"superseded_by,omitempty"` + Closes []string `json:"closes,omitempty"` + Supersedes []string `json:"supersedes,omitempty"` + Refs []refJSON `json:"refs,omitempty"` + Heat float64 `json:"heat"` + InDegree int `json:"in_degree"` + Summary string `json:"summary,omitempty"` + Body string `json:"body,omitempty"` + Canonical string `json:"canonical,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Class model.ProcedureClass `json:"class,omitempty"` + Actor string `json:"actor,omitempty"` + Override string `json:"override,omitempty"` + Index *factIndexJSON `json:"index,omitempty"` + AnnotationTopics []annotationTopicJSON `json:"annotation_topics,omitempty"` + FocusActors []string `json:"focus_actors,omitempty"` + FocusWhen *focusWhenJSON `json:"focus_when,omitempty"` + Involvement []involvementJSON `json:"involvement,omitempty"` + Preflight string `json:"preflight,omitempty"` + Warnings []warningJSON `json:"warnings,omitempty"` + Attachments []exportAttachmentJSON `json:"attachments,omitempty"` +} + +type selectionJSON struct { + Mode query.ExportDependencies `json:"mode"` + Hops *int `json:"hops,omitempty"` + TotalEntries int `json:"total_entries"` +} + +type refJSON struct { + ID string `json:"id"` + Kind model.RefKind `json:"kind"` + Desc string `json:"desc,omitempty"` +} + +type factIndexJSON struct { + Title string `json:"title"` + Topic string `json:"topic,omitempty"` +} + +type annotationTopicJSON struct { + Label string `json:"label"` + Members []string `json:"members,omitempty"` +} + +// involvementJSON carries the effective actors and scope, focus-level +// defaults already applied. +type involvementJSON struct { + Target string `json:"target"` + Actors []string `json:"actors"` + When *focusWhenJSON `json:"when,omitempty"` +} + +type focusWhenJSON struct { + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} + +type warningJSON struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Message string `json:"message"` +} + +type exportAttachmentJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Content string `json:"content,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +type exportWIPJSON struct { + ID string `json:"id"` + Entry string `json:"entry"` + Participant string `json:"participant"` + Exclusive bool `json:"exclusive,omitempty"` + Branch string `json:"branch,omitempty"` + Description string `json:"description,omitempty"` + Time string `json:"time"` +} + +type loadIssueJSON struct { + Ref string `json:"ref"` + Message string `json:"message"` +} + +// RenderExportJSON writes the experimental whole-graph export document. +func RenderExportJSON(w io.Writer, r *query.ExportResult) error { + out := exportJSON{ + Experimental: true, GeneratedAt: r.GeneratedAt.Format(time.RFC3339), + Repos: []exportRepoJSON{}, Embedded: []exportEntryJSON{}, + } + for _, repo := range r.Repos { + rj := exportRepoJSON{ + RepoID: repo.RepoID, Local: repo.Local, Unavailable: repo.Unavailable, Revision: repo.Revision, + Entries: []exportEntryJSON{}, WIP: []exportWIPJSON{}, + } + if sel := repo.Selection; sel != nil { + rj.Selection = &selectionJSON{Mode: sel.Mode, TotalEntries: sel.TotalEntries} + if sel.Mode == query.ExportDependenciesReferenced { + rj.Selection.Hops = &sel.Hops + } + } + for _, e := range repo.Entries { + rj.Entries = append(rj.Entries, exportEntryJSONFrom(e)) + } + for _, m := range repo.WIP { + rj.WIP = append(rj.WIP, exportWIPJSON{ + ID: m.ID, Entry: m.Entry, Participant: m.Participant, Exclusive: m.Exclusive, + Branch: m.Branch, Description: m.Content, Time: m.Time.Format(wallClockLayout), + }) + } + for _, issue := range repo.LoadIssues { + rj.LoadIssues = append(rj.LoadIssues, loadIssueJSON(issue)) + } + out.Repos = append(out.Repos, rj) + } + for _, e := range r.Embedded { + out.Embedded = append(out.Embedded, exportEntryJSONFrom(e)) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func exportEntryJSONFrom(x query.ExportEntry) exportEntryJSON { + e := x.Entry + ej := exportEntryJSON{ + ID: e.ID, FullID: x.FullID, Type: e.Type, Kind: e.Kind, Layer: e.Layer, + Confidence: e.Confidence, Intent: e.Intent, Participants: e.Participants, + Time: e.Time.Format(wallClockLayout), Status: x.Status.Kind, StatusBy: x.Status.By, + ClosedBy: x.ClosedBy, SupersededBy: x.SupersededBy, Closes: e.Closes, Supersedes: e.Supersedes, + Heat: x.Heat, InDegree: x.InDegree, Summary: e.Summary, Body: e.Content, + Canonical: e.Canonical, Aliases: e.Aliases, Class: e.Class, Actor: e.Actor, Override: e.Override, + FocusActors: e.FocusActors, FocusWhen: focusWhenJSONFrom(e.FocusWhen), Preflight: e.Preflight, + } + if p, err := model.ParseID(e.ID); err == nil { + ej.ShortID = p.TypeCode + "-" + p.LayerCode + "-" + p.Suffix + } + for _, t := range x.Topics { + ej.Topics = append(ej.Topics, t.String()) + } + for _, ref := range e.Refs { + ej.Refs = append(ej.Refs, refJSON(ref)) + } + if e.Index != nil { + ej.Index = &factIndexJSON{Title: e.Index.Title} + if len(e.Index.Topic.Components) > 0 { + ej.Index.Topic = e.Index.Topic.String() + } + } + for _, t := range e.AnnotationTopics { + ej.AnnotationTopics = append(ej.AnnotationTopics, annotationTopicJSON(t)) + } + for _, inv := range e.Involvement { + actors := e.ResolveActors(inv) + if actors == nil { + actors = []string{} + } + ej.Involvement = append(ej.Involvement, involvementJSON{Target: inv.Target, Actors: actors, When: focusWhenJSONFrom(e.ResolveWhen(inv))}) + } + for _, w := range e.Warnings { + ej.Warnings = append(ej.Warnings, warningJSON(w)) + } + for _, a := range x.Attachments { + ej.Attachments = append(ej.Attachments, exportAttachmentJSON(a)) + } + return ej +} + +func focusWhenJSONFrom(w *model.FocusWhen) *focusWhenJSON { + if w == nil { + return nil + } + return &focusWhenJSON{From: w.From, To: w.To} +} diff --git a/internal/query/export.go b/internal/query/export.go new file mode 100644 index 00000000..8dcaaafb --- /dev/null +++ b/internal/query/export.go @@ -0,0 +1,109 @@ +package query + +import ( + "fmt" + "time" + + "github.com/networkteam/sdd/internal/model" +) + +// ExportDependencies selects what each dependency repo contributes to an +// export. +type ExportDependencies string + +const ( + // ExportDependenciesNone exports the local graph only. + ExportDependenciesNone ExportDependencies = "none" + // ExportDependenciesReferenced exports the dependency entries the local + // graph cites, expanded upstream by ExportQuery.Hops (model.CitedAcross). + ExportDependenciesReferenced ExportDependencies = "referenced" + // ExportDependenciesAll exports whole dependency graphs. + ExportDependenciesAll ExportDependencies = "all" +) + +// ParseExportDependencies resolves a dependency selection mode by name. +func ParseExportDependencies(s string) (ExportDependencies, error) { + switch mode := ExportDependencies(s); mode { + case ExportDependenciesNone, ExportDependenciesReferenced, ExportDependenciesAll: + return mode, nil + } + return "", fmt.Errorf("unknown dependency selection %q (known: none, referenced, all)", s) +} + +// ExportQuery captures intent to export the whole graph as one structured +// document. EXPERIMENTAL: built on the internal model; the shape is not a +// stable read contract. +type ExportQuery struct { + // Dependencies selects what each dependency repo contributes. + Dependencies ExportDependencies + // DependencyIDs is the local repo's declared dependency closure, in walk + // order (model.DependencyClosure). + DependencyIDs []string + // Hops bounds the upstream expansion of ExportDependenciesReferenced. + Hops int + // Now is the reference time heat is computed against. + Now time.Time +} + +// ExportAttachmentTextLimit caps the inlined content of a text attachment. +const ExportAttachmentTextLimit = 16 * 1024 + +// ExportResult is the structured output of an ExportQuery: the local graph +// first, then each dependency repo. Embedded carries the base entries +// compiled into the binary once, outside every repo: they are identical in +// each graph, so any repo's ID for one resolves there unless that repo has +// its own entry with the ID. They are collected from every exported graph, +// so a base entry the local graph overrides stays listed for a dependency +// that references the original; each is derived in the first exported graph +// carrying it, the local graph first. +type ExportResult struct { + GeneratedAt time.Time + Repos []ExportRepo + Embedded []ExportEntry +} + +// ExportRepo is one exported graph. Unavailable marks a dependency with no +// loadable cache. Revision is empty when the graph is not inside a git work +// tree. Selection is set for dependency repos. +type ExportRepo struct { + RepoID string + Local bool + Unavailable bool + Revision string + Selection *ExportSelection + Entries []ExportEntry + WIP []*model.WIPMarker + LoadIssues []model.LoadIssue +} + +// ExportSelection records how much of a dependency graph was exported: +// TotalEntries counts the repo's own entries, exported or not. +type ExportSelection struct { + Mode ExportDependencies + Hops int + TotalEntries int +} + +// ExportEntry pairs an entry with the attributes the engine derives for it in +// its owning graph. FullID is set for entries of a dependency repo. +type ExportEntry struct { + Entry *model.Entry + FullID string + Status model.Status + ClosedBy []string + SupersededBy []string + Topics []model.TopicPath + Heat float64 + InDegree int + Attachments []ExportAttachment +} + +// ExportAttachment describes one attachment file; Content is set only for +// Markdown and plain-text attachments, cut to ExportAttachmentTextLimit. +type ExportAttachment struct { + Name string + Size int64 + ContentType string + Content string + Truncated bool +}