chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// Package docs generates a "living changelog + ownership + blame +
|
||||
// stale code" bundle from the in-memory graph. Output is markdown or
|
||||
// JSON; the CLI verb `gortex docs` and the MCP tool `generate_docs`
|
||||
// both call into here.
|
||||
//
|
||||
// The package depends on:
|
||||
// - graph.Graph (for symbol nodes and blame metadata).
|
||||
// - optional indexer.HistoryProvider (for recent change events).
|
||||
// - blame.EnrichGraph (callable via the BlameRunner injection).
|
||||
//
|
||||
// All four sections are independent — passing Sections lets the caller
|
||||
// pick a subset (e.g. only recent changes for a post-commit hook).
|
||||
package docs
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// HistoryEvent is the docs-package projection of one watcher event.
|
||||
// Kept local so the package doesn't import internal/indexer.
|
||||
type HistoryEvent struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Kind string `json:"kind"`
|
||||
NodesAdded int `json:"nodes_added"`
|
||||
NodesRemoved int `json:"nodes_removed"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// HistoryProvider is the minimal surface docs needs from the indexer.
|
||||
// Both indexer.Watcher and indexer.MultiWatcher already implement
|
||||
// HistorySince natively; the MCP / CLI callers adapt them through a
|
||||
// thin shim.
|
||||
type HistoryProvider interface {
|
||||
HistorySince(since time.Time) []HistoryEvent
|
||||
}
|
||||
|
||||
// BlameRunner re-runs git blame across the indexed repos and stamps
|
||||
// meta.last_authored on every blame-eligible node. Returns the number
|
||||
// of nodes enriched per repo prefix and an aggregate total.
|
||||
type BlameRunner func() (total int, perRepo map[string]int, err error)
|
||||
|
||||
// Options controls what the bundle includes.
|
||||
type Options struct {
|
||||
// Since narrows recent changes. Zero means "last 7 days".
|
||||
Since time.Duration
|
||||
// Top caps every list section. Zero means 20.
|
||||
Top int
|
||||
// Sections is an ordered allow-list. Empty means all four:
|
||||
// "recent", "ownership", "stale", "blame".
|
||||
Sections []string
|
||||
// PathPrefix restricts ownership/stale rows to this file prefix.
|
||||
PathPrefix string
|
||||
// MinSymbols filters ownership table.
|
||||
MinSymbols int
|
||||
// IncludeBlame triggers BlameRunner. False when blame metadata
|
||||
// is already enriched and a re-run isn't desired.
|
||||
IncludeBlame bool
|
||||
// WorkspaceID restricts nodes to a single workspace. Empty
|
||||
// means "all workspaces".
|
||||
WorkspaceID string
|
||||
}
|
||||
|
||||
// Bundle is the canonical structured result. RenderMarkdown / RenderJSON
|
||||
// project it to one of the two wire formats.
|
||||
type Bundle struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Since time.Time `json:"since"`
|
||||
RecentChanges []HistoryEvent `json:"recent_changes,omitempty"`
|
||||
OwnershipRows []OwnershipRow `json:"ownership,omitempty"`
|
||||
StaleCodeRows []StaleCodeRow `json:"stale_code,omitempty"`
|
||||
Blame *BlameSummary `json:"blame,omitempty"`
|
||||
Sections []string `json:"sections"`
|
||||
}
|
||||
|
||||
// OwnershipRow describes one author's stake in the codebase.
|
||||
type OwnershipRow struct {
|
||||
Email string `json:"email"`
|
||||
Symbols int `json:"symbols"`
|
||||
Files int `json:"files"`
|
||||
Oldest int64 `json:"oldest_timestamp"`
|
||||
Newest int64 `json:"newest_timestamp"`
|
||||
}
|
||||
|
||||
// StaleCodeRow describes one stale symbol.
|
||||
type StaleCodeRow struct {
|
||||
ID string `json:"id"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Email string `json:"email"`
|
||||
Commit string `json:"commit"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
AgeDays int `json:"age_days"`
|
||||
}
|
||||
|
||||
// BlameSummary captures the result of a blame re-run.
|
||||
type BlameSummary struct {
|
||||
Enriched int `json:"enriched"`
|
||||
PerRepo map[string]int `json:"per_repo"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Deps bundles the runtime dependencies injected by the MCP/CLI layer.
|
||||
type Deps struct {
|
||||
Graph graph.Store
|
||||
History HistoryProvider
|
||||
Blame BlameRunner
|
||||
}
|
||||
|
||||
// Generate assembles a Bundle by walking the graph and (when
|
||||
// requested) calling the blame runner. The output is deterministic
|
||||
// for a fixed graph + clock.
|
||||
func Generate(deps Deps, opts Options) (*Bundle, error) {
|
||||
if deps.Graph == nil {
|
||||
return nil, errNilGraph
|
||||
}
|
||||
if opts.Since == 0 {
|
||||
opts.Since = 7 * 24 * time.Hour
|
||||
}
|
||||
if opts.Top == 0 {
|
||||
opts.Top = 20
|
||||
}
|
||||
if opts.MinSymbols == 0 {
|
||||
opts.MinSymbols = 1
|
||||
}
|
||||
|
||||
sections := opts.Sections
|
||||
if len(sections) == 0 {
|
||||
sections = []string{"recent", "ownership", "stale", "blame"}
|
||||
}
|
||||
want := make(map[string]bool, len(sections))
|
||||
for _, s := range sections {
|
||||
want[strings.ToLower(strings.TrimSpace(s))] = true
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
since := now.Add(-opts.Since)
|
||||
|
||||
bundle := &Bundle{
|
||||
GeneratedAt: now,
|
||||
Since: since,
|
||||
Sections: sections,
|
||||
}
|
||||
|
||||
// Recent changes — relies on a watcher being attached.
|
||||
if want["recent"] && deps.History != nil {
|
||||
evs := deps.History.HistorySince(since)
|
||||
if len(evs) > opts.Top {
|
||||
evs = evs[len(evs)-opts.Top:]
|
||||
}
|
||||
bundle.RecentChanges = evs
|
||||
}
|
||||
|
||||
// Ownership / stale code — read blame metadata stamped on nodes.
|
||||
if want["ownership"] || want["stale"] {
|
||||
ownerRows, staleRows := walkNodes(deps.Graph, opts, now)
|
||||
if want["ownership"] {
|
||||
if len(ownerRows) > opts.Top {
|
||||
ownerRows = ownerRows[:opts.Top]
|
||||
}
|
||||
bundle.OwnershipRows = ownerRows
|
||||
}
|
||||
if want["stale"] {
|
||||
if len(staleRows) > opts.Top {
|
||||
staleRows = staleRows[:opts.Top]
|
||||
}
|
||||
bundle.StaleCodeRows = staleRows
|
||||
}
|
||||
}
|
||||
|
||||
// Blame re-run (optional; usually run on-demand by the post-commit hook).
|
||||
if want["blame"] && deps.Blame != nil && opts.IncludeBlame {
|
||||
total, perRepo, err := deps.Blame()
|
||||
sum := &BlameSummary{
|
||||
Enriched: total,
|
||||
PerRepo: perRepo,
|
||||
}
|
||||
if err != nil {
|
||||
sum.Error = err.Error()
|
||||
}
|
||||
bundle.Blame = sum
|
||||
}
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// walkNodes does a single pass over symbol nodes and emits the
|
||||
// ownership and stale-code tables in a single pass.
|
||||
func walkNodes(g graph.Store, opts Options, now time.Time) ([]OwnershipRow, []StaleCodeRow) {
|
||||
type ownerStats struct {
|
||||
row OwnershipRow
|
||||
fileSet map[string]struct{}
|
||||
}
|
||||
owners := make(map[string]*ownerStats)
|
||||
var stale []StaleCodeRow
|
||||
cutoffSec := now.Add(-365 * 24 * time.Hour).Unix() // default 365d
|
||||
if opts.Since > 0 {
|
||||
// stale uses a separate cutoff (always 365d by spec), keep simple.
|
||||
cutoffSec = now.Add(-365 * 24 * time.Hour).Unix()
|
||||
}
|
||||
|
||||
allowed := map[graph.NodeKind]struct{}{
|
||||
graph.KindFunction: {},
|
||||
graph.KindMethod: {},
|
||||
}
|
||||
|
||||
for _, n := range g.AllNodes() {
|
||||
if _, ok := allowed[n.Kind]; !ok {
|
||||
continue
|
||||
}
|
||||
if opts.PathPrefix != "" && !strings.HasPrefix(n.FilePath, opts.PathPrefix) {
|
||||
continue
|
||||
}
|
||||
if opts.WorkspaceID != "" {
|
||||
ws := n.WorkspaceID
|
||||
if ws == "" {
|
||||
ws = n.RepoPrefix
|
||||
}
|
||||
if ws != opts.WorkspaceID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
la, ok := n.Meta["last_authored"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := la["email"].(string)
|
||||
commit, _ := la["commit"].(string)
|
||||
ts := tsFromMeta(la["timestamp"])
|
||||
if ts == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
s, ok := owners[email]
|
||||
if !ok {
|
||||
s = &ownerStats{
|
||||
row: OwnershipRow{Email: email, Oldest: ts, Newest: ts},
|
||||
fileSet: map[string]struct{}{},
|
||||
}
|
||||
owners[email] = s
|
||||
}
|
||||
s.row.Symbols++
|
||||
s.fileSet[n.FilePath] = struct{}{}
|
||||
if ts < s.row.Oldest {
|
||||
s.row.Oldest = ts
|
||||
}
|
||||
if ts > s.row.Newest {
|
||||
s.row.Newest = ts
|
||||
}
|
||||
}
|
||||
|
||||
if ts < cutoffSec {
|
||||
stale = append(stale, StaleCodeRow{
|
||||
ID: n.ID,
|
||||
File: n.FilePath,
|
||||
Line: n.StartLine,
|
||||
Email: email,
|
||||
Commit: commit,
|
||||
Timestamp: ts,
|
||||
AgeDays: int((now.Unix() - ts) / (24 * 3600)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ownerRows := make([]OwnershipRow, 0, len(owners))
|
||||
for _, s := range owners {
|
||||
if s.row.Symbols < opts.MinSymbols {
|
||||
continue
|
||||
}
|
||||
s.row.Files = len(s.fileSet)
|
||||
ownerRows = append(ownerRows, s.row)
|
||||
}
|
||||
sort.Slice(ownerRows, func(i, j int) bool {
|
||||
if ownerRows[i].Symbols != ownerRows[j].Symbols {
|
||||
return ownerRows[i].Symbols > ownerRows[j].Symbols
|
||||
}
|
||||
return ownerRows[i].Email < ownerRows[j].Email
|
||||
})
|
||||
sort.Slice(stale, func(i, j int) bool {
|
||||
return stale[i].Timestamp < stale[j].Timestamp
|
||||
})
|
||||
return ownerRows, stale
|
||||
}
|
||||
|
||||
// tsFromMeta normalises int64 (in-process) vs float64 (gob-decoded
|
||||
// snapshot) timestamps so this package works on both code paths —
|
||||
// same trick the MCP handlers use today.
|
||||
func tsFromMeta(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int:
|
||||
return int64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// errNilGraph is returned by Generate when no graph is supplied.
|
||||
type sentinelError string
|
||||
|
||||
func (e sentinelError) Error() string { return string(e) }
|
||||
|
||||
const errNilGraph sentinelError = "docs: graph is required"
|
||||
@@ -0,0 +1,157 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func buildBlamedGraph(t *testing.T) *graph.Graph {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
now := time.Now().Unix()
|
||||
staleTS := time.Now().Add(-400 * 24 * time.Hour).Unix()
|
||||
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "pkg/a.go::Recent", Kind: graph.KindFunction, Name: "Recent",
|
||||
FilePath: "pkg/a.go", StartLine: 10, Language: "go",
|
||||
Meta: map[string]any{
|
||||
"last_authored": map[string]any{
|
||||
"email": "alice@example.com", "timestamp": now,
|
||||
"commit": "deadbeef1234",
|
||||
},
|
||||
},
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "pkg/a.go::Old", Kind: graph.KindFunction, Name: "Old",
|
||||
FilePath: "pkg/a.go", StartLine: 30, Language: "go",
|
||||
Meta: map[string]any{
|
||||
"last_authored": map[string]any{
|
||||
"email": "alice@example.com", "timestamp": staleTS,
|
||||
"commit": "abcd5678abcd",
|
||||
},
|
||||
},
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "pkg/b.go::Bob", Kind: graph.KindMethod, Name: "Bob",
|
||||
FilePath: "pkg/b.go", StartLine: 5, Language: "go",
|
||||
Meta: map[string]any{
|
||||
"last_authored": map[string]any{
|
||||
"email": "bob@example.com", "timestamp": now,
|
||||
"commit": "01234567",
|
||||
},
|
||||
},
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
type fakeHistory struct {
|
||||
events []HistoryEvent
|
||||
}
|
||||
|
||||
func (f *fakeHistory) HistorySince(since time.Time) []HistoryEvent {
|
||||
var out []HistoryEvent
|
||||
for _, e := range f.events {
|
||||
if e.Timestamp.After(since) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestGenerate_AllSections(t *testing.T) {
|
||||
g := buildBlamedGraph(t)
|
||||
hist := &fakeHistory{events: []HistoryEvent{
|
||||
{FilePath: "pkg/a.go", Kind: "modified", NodesAdded: 1, NodesRemoved: 0, Timestamp: time.Now()},
|
||||
{FilePath: "pkg/b.go", Kind: "created", NodesAdded: 3, NodesRemoved: 0, Timestamp: time.Now()},
|
||||
}}
|
||||
bundle, err := Generate(Deps{Graph: g, History: hist}, Options{Since: 24 * time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(bundle.RecentChanges) != 2 {
|
||||
t.Errorf("recent changes = %d, want 2", len(bundle.RecentChanges))
|
||||
}
|
||||
if len(bundle.OwnershipRows) != 2 {
|
||||
t.Errorf("owners = %d, want 2", len(bundle.OwnershipRows))
|
||||
}
|
||||
if len(bundle.StaleCodeRows) != 1 {
|
||||
t.Errorf("stale rows = %d, want 1", len(bundle.StaleCodeRows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_SectionsFilter(t *testing.T) {
|
||||
g := buildBlamedGraph(t)
|
||||
bundle, err := Generate(Deps{Graph: g},
|
||||
Options{Sections: []string{"stale"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(bundle.OwnershipRows) != 0 {
|
||||
t.Errorf("ownership shouldn't render when not in sections; got %d", len(bundle.OwnershipRows))
|
||||
}
|
||||
if len(bundle.RecentChanges) != 0 {
|
||||
t.Errorf("recent shouldn't render; got %d", len(bundle.RecentChanges))
|
||||
}
|
||||
if len(bundle.StaleCodeRows) != 1 {
|
||||
t.Errorf("stale rows = %d, want 1", len(bundle.StaleCodeRows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_HasSections(t *testing.T) {
|
||||
g := buildBlamedGraph(t)
|
||||
bundle, err := Generate(Deps{Graph: g}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
md := RenderMarkdown(bundle)
|
||||
for _, want := range []string{"# Docs Bundle", "## Recent Changes", "## Ownership", "## Stale Code", "## Blame Enrichment"} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJSON_RoundTrips(t *testing.T) {
|
||||
g := buildBlamedGraph(t)
|
||||
bundle, err := Generate(Deps{Graph: g}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
data, err := RenderJSON(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderJSON: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"sections"`) {
|
||||
t.Error("JSON should include sections field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_WithBlame(t *testing.T) {
|
||||
g := buildBlamedGraph(t)
|
||||
called := 0
|
||||
bundle, err := Generate(Deps{
|
||||
Graph: g,
|
||||
Blame: func() (int, map[string]int, error) {
|
||||
called++
|
||||
return 42, map[string]int{"gortex": 42}, nil
|
||||
},
|
||||
}, Options{IncludeBlame: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Errorf("blame runner called %d times, want 1", called)
|
||||
}
|
||||
if bundle.Blame == nil || bundle.Blame.Enriched != 42 {
|
||||
t.Fatalf("blame summary missing or wrong: %+v", bundle.Blame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_NilGraphFails(t *testing.T) {
|
||||
if _, err := Generate(Deps{}, Options{}); err == nil {
|
||||
t.Error("expected error when graph is nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RenderMarkdown projects a Bundle to markdown. Section order follows
|
||||
// Bundle.Sections.
|
||||
func RenderMarkdown(b *Bundle) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "# Docs Bundle\n\n")
|
||||
fmt.Fprintf(&sb, "Generated %s. Window since %s.\n\n",
|
||||
b.GeneratedAt.Format(time.RFC3339), b.Since.Format(time.RFC3339))
|
||||
|
||||
for _, section := range b.Sections {
|
||||
switch strings.ToLower(section) {
|
||||
case "recent":
|
||||
renderRecent(&sb, b.RecentChanges)
|
||||
case "ownership":
|
||||
renderOwnership(&sb, b.OwnershipRows)
|
||||
case "stale":
|
||||
renderStale(&sb, b.StaleCodeRows)
|
||||
case "blame":
|
||||
renderBlame(&sb, b.Blame)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// RenderJSON marshals a Bundle to indented JSON.
|
||||
func RenderJSON(b *Bundle) ([]byte, error) {
|
||||
return json.MarshalIndent(b, "", " ")
|
||||
}
|
||||
|
||||
func renderRecent(sb *strings.Builder, evs []HistoryEvent) {
|
||||
sb.WriteString("## Recent Changes\n\n")
|
||||
if len(evs) == 0 {
|
||||
sb.WriteString("_No file changes recorded in this window. (Was the watcher attached?)_\n\n")
|
||||
return
|
||||
}
|
||||
sb.WriteString("| Timestamp | Kind | File | +Nodes | -Nodes |\n")
|
||||
sb.WriteString("|-----------|------|------|--------|--------|\n")
|
||||
for _, ev := range evs {
|
||||
fmt.Fprintf(sb, "| %s | %s | `%s` | %d | %d |\n",
|
||||
ev.Timestamp.Format(time.RFC3339), ev.Kind, ev.FilePath,
|
||||
ev.NodesAdded, ev.NodesRemoved)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func renderOwnership(sb *strings.Builder, rows []OwnershipRow) {
|
||||
sb.WriteString("## Ownership\n\n")
|
||||
if len(rows) == 0 {
|
||||
sb.WriteString("_No blame metadata on graph nodes. Run `gortex enrich blame` or `analyze kind:blame` first._\n\n")
|
||||
return
|
||||
}
|
||||
sb.WriteString("| Author | Symbols | Files | Oldest | Newest |\n")
|
||||
sb.WriteString("|--------|---------|-------|--------|--------|\n")
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(sb, "| %s | %d | %d | %s | %s |\n",
|
||||
r.Email, r.Symbols, r.Files,
|
||||
formatTimestamp(r.Oldest), formatTimestamp(r.Newest))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func renderStale(sb *strings.Builder, rows []StaleCodeRow) {
|
||||
sb.WriteString("## Stale Code\n\n")
|
||||
if len(rows) == 0 {
|
||||
sb.WriteString("_No stale code older than 365 days, or blame metadata missing._\n\n")
|
||||
return
|
||||
}
|
||||
sb.WriteString("| Age (days) | Symbol | Author | Commit |\n")
|
||||
sb.WriteString("|------------|--------|--------|--------|\n")
|
||||
for _, r := range rows {
|
||||
commitShort := r.Commit
|
||||
if len(commitShort) > 8 {
|
||||
commitShort = commitShort[:8]
|
||||
}
|
||||
sym := r.ID
|
||||
if r.File != "" {
|
||||
sym = fmt.Sprintf("`%s` (%s:%d)", r.ID, r.File, r.Line)
|
||||
}
|
||||
fmt.Fprintf(sb, "| %d | %s | %s | `%s` |\n",
|
||||
r.AgeDays, sym, r.Email, commitShort)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func renderBlame(sb *strings.Builder, sum *BlameSummary) {
|
||||
sb.WriteString("## Blame Enrichment\n\n")
|
||||
if sum == nil {
|
||||
sb.WriteString("_Blame re-run was not requested in this bundle._\n\n")
|
||||
return
|
||||
}
|
||||
if sum.Error != "" {
|
||||
fmt.Fprintf(sb, "Error: `%s`\n\n", sum.Error)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "Enriched %d nodes.\n\n", sum.Enriched)
|
||||
if len(sum.PerRepo) > 0 {
|
||||
sb.WriteString("| Repo prefix | Nodes enriched |\n")
|
||||
sb.WriteString("|-------------|----------------|\n")
|
||||
for repo, count := range sum.PerRepo {
|
||||
fmt.Fprintf(sb, "| `%s` | %d |\n", repo, count)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func formatTimestamp(ts int64) string {
|
||||
if ts == 0 {
|
||||
return "—"
|
||||
}
|
||||
return time.Unix(ts, 0).UTC().Format("2006-01-02")
|
||||
}
|
||||
Reference in New Issue
Block a user