package beads
import "strings"
// depTreeMaxDepth / depTreeMaxNodes bound the transitive walk so a dense or
// cyclic graph can never blow up a detail page.
const (
depTreeMaxDepth = 6
depTreeMaxNodes = 200
)
// depLink is one outgoing edge in a dependency adjacency map: the neighbor id
// and the edge's dependency type.
type depLink struct {
to string
typ string
}
// buildDepTree walks the adjacency from root (exclusive) breadth-consistent
// pre-order, flattening the reachable set into indented nodes. Each issue
// appears once (first path wins); depth and node count are bounded so a dense
// or cyclic graph is safe.
func buildDepTree(root string, adj map[string][]depLink, titleOf, statusOf, catByStatus map[string]string) []TreeNode {
var out []TreeNode
visited := map[string]bool{root: true}
var dfs func(id string, depth int)
dfs = func(id string, depth int) {
if depth > depTreeMaxDepth || len(out) >= depTreeMaxNodes {
return
}
for _, lnk := range adj[id] {
if visited[lnk.to] || len(out) >= depTreeMaxNodes {
continue
}
visited[lnk.to] = true
st := statusOf[lnk.to]
out = append(out, TreeNode{
ID: lnk.to,
Title: titleOf[lnk.to],
Type: lnk.typ,
Status: st,
Closed: statusCategory(st, catByStatus) == "closed",
Depth: depth,
})
dfs(lnk.to, depth+1)
}
}
dfs(root, 0)
return out
}
// hasTransitive reports whether a flattened tree reaches past the direct edges
// (any Depth>0 node) — the signal that it adds something the flat list doesn't.
func hasTransitive(nodes []TreeNode) bool {
for _, n := range nodes {
if n.Depth > 0 {
return true
}
}
return false
}
// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks
// linked under an epic — still appear on the timeline. Returns ok=false when the
// edge does not touch `want` or the row has no timestamp (older schema without
// created_at: skip rather than emit a blank-dated entry).
func depActivity(want, from, to, typ, at, by string) (Activity, bool) {
if at == "" || (from != want && to != want) {
return Activity{}, false
}
var summary string
switch strings.ToLower(strings.TrimSpace(typ)) {
case "parent-child":
if to == want {
summary = "added subtask " + from // want is the epic/parent
} else {
summary = "added under epic " + to // want is the child
}
case "blocks":
if from == want {
summary = "added dependency on " + to
} else {
summary = from + " now depends on this"
}
case "related":
// Related is symmetric; emit once (from the issue_id side) to avoid a
// duplicate entry on both endpoints.
if from != want {
return Activity{}, false
}
summary = "linked " + to + " (related)"
default:
if from == want {
summary = "added " + typ + " dependency on " + to
} else {
return Activity{}, false
}
}
return Activity{Kind: "dep", Event: "dependency", Actor: by, Summary: summary, CreatedAt: at}, true
}