From 3ad1189ad394b399c78fd6ade0102f823baf7f92 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 15:25:31 -0400 Subject: [PATCH] feat(visualize): render the cross-repo flow as per-repo lanes Signed-off-by: Joshua Temple --- internal/graph/command.go | 6 +- internal/graph/graph.go | 23 ++- internal/graph/graph_test.go | 69 +++++++ internal/visualize/crossrepo.go | 111 +++++++++++ internal/visualize/crossrepo_test.go | 223 ++++++++++++++++++++++ internal/visualize/mermaid.go | 101 +++++++++- internal/visualize/stages.go | 44 +++-- internal/visualize/testdata/crossrepo.mmd | 34 ++++ internal/visualize/theme.go | 2 + internal/visualize/viewmodel.go | 34 +++- 10 files changed, 613 insertions(+), 34 deletions(-) create mode 100644 internal/visualize/crossrepo.go create mode 100644 internal/visualize/crossrepo_test.go create mode 100644 internal/visualize/testdata/crossrepo.mmd diff --git a/internal/graph/command.go b/internal/graph/command.go index 24e092cb..55e50ff7 100644 --- a/internal/graph/command.go +++ b/internal/graph/command.go @@ -27,7 +27,9 @@ The --granularity flag chooses the projection: jobs renders the full job dependency graph (hard dependencies as solid arrows, optional ordering-only ones as dotted arrows); stages renders the coarse lifecycle flow from trunk through build, deploy, and promote to release; env renders the promotion state machine, -including any hotfix divergence and rejoin. +including any hotfix divergence and rejoin; cross-repo renders the multi-repo +flow, a lane per repository with the primary coordinating its dependent +satellites and any satellite-to-primary notify edge. graph is read-only: it never writes files, runs git, or modifies the repo. A missing or invalid manifest is reported as an error.`, @@ -40,7 +42,7 @@ missing or invalid manifest is reported as an error.`, cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") - cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported values: jobs, stages, env") + cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported values: jobs, stages, env, cross-repo") cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported value: mermaid") cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme: cascade, bland, or a path to a JSON theme file") diff --git a/internal/graph/graph.go b/internal/graph/graph.go index d39fcb01..ecc04bb9 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -24,9 +24,10 @@ type Granularity string // Granularity values. const ( - GranularityJobs Granularity = "jobs" - GranularityStages Granularity = "stages" - GranularityEnv Granularity = "env" + GranularityJobs Granularity = "jobs" + GranularityStages Granularity = "stages" + GranularityEnv Granularity = "env" + GranularityCrossRepo Granularity = "cross-repo" ) // formatMermaid is the only diagram format cascade graph emits today. The flag @@ -80,11 +81,11 @@ func Run(o Options, stdout io.Writer) error { granularity = string(GranularityJobs) } switch Granularity(granularity) { - case GranularityJobs, GranularityStages, GranularityEnv: + case GranularityJobs, GranularityStages, GranularityEnv, GranularityCrossRepo: // Each granularity maps to a supported projection. default: - return fmt.Errorf("unknown granularity %q: supported values are %q, %q, and %q", - granularity, GranularityJobs, GranularityStages, GranularityEnv) + return fmt.Errorf("unknown granularity %q: supported values are %q, %q, %q, and %q", + granularity, GranularityJobs, GranularityStages, GranularityEnv, GranularityCrossRepo) } theme, err := resolveTheme(o.Theme) @@ -165,6 +166,16 @@ func buildView(granularity Granularity, configPath, key string) (visualize.ViewM return visualize.ViewModel{}, fmt.Errorf("building stages view: %w", err) } return vm, nil + case GranularityCrossRepo: + cfg, err := config.ParseWithKey(configPath, key) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err) + } + vm, err := visualize.BuildCrossRepoViewModel(cfg) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("building cross-repo view: %w", err) + } + return vm, nil default: cfg, err := config.ParseWithKey(configPath, key) if err != nil { diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index cc68fad0..f8bbc499 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -147,6 +147,75 @@ func TestRun_EnvGranularity_EmitsStateMachine(t *testing.T) { require.Contains(t, got, "dev_hotfix --> prod : rejoin") } +// writeCrossRepoManifest lays down a primary manifest that coordinates one +// external satellite and notifies an upstream primary, so the cross-repo +// projection renders a lane per repo and edges in both directions end to end. +func writeCrossRepoManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, + }, + External: []config.ExternalRepoConfig{ + { + Repo: "org/cdk-infra", + Deploys: []config.ExternalDeployConfig{ + {Name: "cdk", Workflow: ".github/workflows/cdk.yaml"}, + }, + }, + }, + Notify: &config.NotifyConfig{Repo: "org/platform"}, + } + manifest := map[string]any{config.DefaultManifestKey: config.CICDFile{Config: cfg}} + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + path := filepath.Join(dir, ".github", "manifest.yaml") + require.NoError(t, os.WriteFile(path, body, 0o644)) + return path +} + +func TestRun_CrossRepoGranularity_EmitsLanesAndEdges(t *testing.T) { + path := writeCrossRepoManifest(t) + o := baseOptions(path) + o.Granularity = string(GranularityCrossRepo) + + var out bytes.Buffer + require.NoError(t, Run(o, &out)) + + got := out.String() + require.Contains(t, got, "flowchart TD") + // A lane per repository. + require.Contains(t, got, `subgraph primary["primary"]`) + require.Contains(t, got, `subgraph repo_org_cdk_infra["org/cdk-infra"]`) + require.Contains(t, got, `subgraph repo_org_platform["org/platform"]`) + // Cross-repo edges in both directions: primary coordinates the dependent and + // the local pipeline notifies its upstream primary. + require.Contains(t, got, "==>|cdk|") + require.Contains(t, got, "-. notify .->") +} + +func TestRun_CrossRepoGranularity_NoExternals_RendersPrimary(t *testing.T) { + // A single-repo manifest with no external coordination still renders its + // primary pipeline with no cross-repo lanes, so the granularity is safe to + // request on any manifest. + path := writeManifest(t) + o := baseOptions(path) + o.Granularity = string(GranularityCrossRepo) + + var out bytes.Buffer + require.NoError(t, Run(o, &out)) + + got := out.String() + require.Contains(t, got, "flowchart TD") + require.Contains(t, got, "release") + require.NotContains(t, got, "subgraph") +} + func TestRun_UnknownGranularity_Errors(t *testing.T) { path := writeManifest(t) o := baseOptions(path) diff --git a/internal/visualize/crossrepo.go b/internal/visualize/crossrepo.go new file mode 100644 index 00000000..7aef48f4 --- /dev/null +++ b/internal/visualize/crossrepo.go @@ -0,0 +1,111 @@ +package visualize + +import ( + "fmt" + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// primaryGroupID and primaryGroupLabel identify the lane that holds the primary +// repo's own pipeline in the cross-repo projection. The id is a fixed slug rather +// than a repo name because the manifest does not carry the primary's own repo +// identity; "primary" reads clearly next to the named dependent lanes. +const ( + primaryGroupID = "primary" + primaryGroupLabel = "primary" +) + +// BuildCrossRepoViewModel projects the manifest's cross-repo coordination into a +// render-agnostic flowchart. The primary repo's lifecycle pipeline forms the root +// lane; each dependent satellite the primary coordinates (cfg.External) becomes +// its own lane holding a node per external deployable, with an edge from the +// primary's pipeline to each one labeled with the deploy it drives; and a notify +// config (cfg.Notify) adds an upstream-primary lane with a notify edge back to +// it. A manifest with no external coordination and no notify renders just the +// primary pipeline with no lanes, so the projection stays sensible for the common +// single-repo case. The model carries no diagram syntax; the emitter draws the +// lanes and the cross-repo edges. +func BuildCrossRepoViewModel(cfg *config.TrunkConfig) (ViewModel, error) { + if cfg == nil { + return ViewModel{}, fmt.Errorf("visualize: nil config") + } + + vm := ViewModel{Kind: DiagramFlowchart} + + // The primary lane is the manifest's own lifecycle pipeline. primaryExit is + // the last stage; cross-repo edges originate there because coordination and + // notification happen once the primary's pipeline has run. + var primaryNodeIDs []string + var prev, primaryExit string + for _, s := range presentStages(cfg) { + vm.Nodes = append(vm.Nodes, Node{ID: s.id, Label: s.label, Kind: NodeStage}) + primaryNodeIDs = append(primaryNodeIDs, s.id) + if prev != "" { + vm.Edges = append(vm.Edges, Edge{From: prev, To: s.id, Kind: EdgeStage}) + } + prev = s.id + primaryExit = s.id + } + + hasExternal := len(cfg.External) > 0 + hasNotify := cfg.Notify != nil && cfg.Notify.Repo != "" + + // No cross-repo relationships: render the primary pipeline alone, with no + // lanes, so the diagram matches the single-repo stages view. + if !hasExternal && !hasNotify { + return vm, nil + } + + // Once dependent or upstream lanes exist, the primary's own pipeline is drawn + // as a lane so a reader can tell which nodes belong to which repo. + vm.Groups = append(vm.Groups, Group{ID: primaryGroupID, Label: primaryGroupLabel, NodeIDs: primaryNodeIDs}) + + for _, ext := range cfg.External { + slug := repoSlug(ext.Repo) + var memberIDs []string + for _, d := range ext.Deploys { + id := "ext_" + slug + "_" + repoSlug(d.Name) + vm.Nodes = append(vm.Nodes, Node{ID: id, Label: d.Name, Kind: NodeDeploy}) + memberIDs = append(memberIDs, id) + vm.Edges = append(vm.Edges, Edge{From: primaryExit, To: id, Kind: EdgeExternal, Label: d.Name}) + } + // A dependent that declares no deployables still renders as a lane with a + // single repo node, so the coordination relationship stays visible. + if len(ext.Deploys) == 0 { + id := "ext_" + slug + vm.Nodes = append(vm.Nodes, Node{ID: id, Label: ext.Repo, Kind: NodeRepo}) + memberIDs = append(memberIDs, id) + vm.Edges = append(vm.Edges, Edge{From: primaryExit, To: id, Kind: EdgeExternal, Label: ext.Repo}) + } + vm.Groups = append(vm.Groups, Group{ID: "repo_" + slug, Label: ext.Repo, NodeIDs: memberIDs}) + } + + if hasNotify { + slug := repoSlug(cfg.Notify.Repo) + id := "notify_" + slug + vm.Nodes = append(vm.Nodes, Node{ID: id, Label: cfg.Notify.Repo, Kind: NodeRepo}) + vm.Edges = append(vm.Edges, Edge{From: primaryExit, To: id, Kind: EdgeNotify, Label: "notify"}) + vm.Groups = append(vm.Groups, Group{ID: "repo_" + slug, Label: cfg.Notify.Repo, NodeIDs: []string{id}}) + } + + return vm, nil +} + +// repoSlug folds a repository reference (for example "org/cdk-infra") into a +// Mermaid-safe identifier fragment by replacing every rune that is not a letter +// or digit with an underscore. The human-facing repo string is kept on the node +// or group label, so only the identity token is slugged. +func repoSlug(repo string) string { + var b strings.Builder + b.Grow(len(repo)) + for _, r := range repo { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} diff --git a/internal/visualize/crossrepo_test.go b/internal/visualize/crossrepo_test.go new file mode 100644 index 00000000..81589ddf --- /dev/null +++ b/internal/visualize/crossrepo_test.go @@ -0,0 +1,223 @@ +package visualize + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// primaryWithDependents is a primary manifest that coordinates two external +// satellite repos and also notifies an upstream primary, so the projection must +// carry a lane per repo and cross-repo edges in both directions. +func primaryWithDependents() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Builds: []config.BuildConfig{ + {Name: "api", Workflow: ".github/workflows/build-api.yaml"}, + }, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, + }, + External: []config.ExternalRepoConfig{ + { + Repo: "org/cdk-infra", + Deploys: []config.ExternalDeployConfig{ + {Name: "cdk", Workflow: ".github/workflows/cdk.yaml"}, + }, + }, + { + Repo: "org/web-edge", + Deploys: []config.ExternalDeployConfig{ + {Name: "edge", Workflow: ".github/workflows/edge.yaml"}, + }, + }, + }, + Notify: &config.NotifyConfig{Repo: "org/platform"}, + } +} + +func TestBuildCrossRepoViewModel_NilConfig(t *testing.T) { + if _, err := BuildCrossRepoViewModel(nil); err == nil { + t.Fatal("expected an error for a nil config, got nil") + } +} + +func TestBuildCrossRepoViewModel_RendersLanesAndEdges(t *testing.T) { + vm, err := BuildCrossRepoViewModel(primaryWithDependents()) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + if vm.Kind != DiagramFlowchart { + t.Errorf("expected DiagramFlowchart, got %q", vm.Kind) + } + + // One lane for the primary plus one per dependent and one for the notified + // upstream: four groups in stable construction order. + var groupLabels []string + for _, g := range vm.Groups { + groupLabels = append(groupLabels, g.Label) + } + want := "primary,org/cdk-infra,org/web-edge,org/platform" + if strings.Join(groupLabels, ",") != want { + t.Fatalf("group lanes mismatch:\n got %v\nwant %s", groupLabels, want) + } + + // The primary lane holds the stage pipeline; the dependent lanes hold the + // external deploy nodes; the notify lane holds the upstream repo node. + nodeKindByID := make(map[string]NodeKind, len(vm.Nodes)) + for _, n := range vm.Nodes { + nodeKindByID[n.ID] = n.Kind + } + + // Cross-repo edges: primary -> each dependent deploy (external), and the + // satellite -> primary notify edge. + var external, notify int + for _, e := range vm.Edges { + switch e.Kind { + case EdgeExternal: + external++ + if e.Label == "" { + t.Errorf("external edge %+v must carry a label", e) + } + case EdgeNotify: + notify++ + if e.Label != "notify" { + t.Errorf("notify edge label = %q, want %q", e.Label, "notify") + } + } + } + if external != 2 { + t.Errorf("expected 2 external edges (one per dependent deploy), got %d", external) + } + if notify != 1 { + t.Errorf("expected 1 notify edge, got %d", notify) + } +} + +func TestBuildCrossRepoViewModel_NoExternals_PrimaryOnly(t *testing.T) { + // A manifest with no external coordination and no notify renders just the + // primary stage pipeline with no cross-repo lanes or edges. + vm, err := BuildCrossRepoViewModel(&config.TrunkConfig{ + TrunkBranch: "main", + Deploys: []config.DeployConfig{{Name: "app", Workflow: ".github/workflows/deploy.yaml"}}, + }) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + if len(vm.Groups) != 0 { + t.Errorf("expected no cross-repo lanes for a manifest with no externals, got %d", len(vm.Groups)) + } + for _, e := range vm.Edges { + if e.Kind == EdgeExternal || e.Kind == EdgeNotify { + t.Errorf("unexpected cross-repo edge in a no-externals manifest: %+v", e) + } + } + if len(vm.Nodes) == 0 { + t.Error("expected the primary stage pipeline to still render") + } +} + +func TestBuildCrossRepoViewModel_SatelliteOnly_NotifiesNamedPrimary(t *testing.T) { + // A satellite manifest has a notify config but no external coordination; it + // must still render its notify edge to the named primary repo. + vm, err := BuildCrossRepoViewModel(&config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Deploys: []config.DeployConfig{{Name: "app", Workflow: ".github/workflows/deploy.yaml"}}, + Notify: &config.NotifyConfig{Repo: "org/my-backend"}, + }) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + + var foundNotify bool + for _, e := range vm.Edges { + if e.Kind == EdgeNotify { + foundNotify = true + } + } + if !foundNotify { + t.Fatal("expected a notify edge to the named primary, found none") + } + + var namedPrimary bool + for _, n := range vm.Nodes { + if n.Kind == NodeRepo && n.Label == "org/my-backend" { + namedPrimary = true + } + } + if !namedPrimary { + t.Error("expected a node for the named primary org/my-backend") + } +} + +func TestMermaidEmitter_CrossRepoGolden(t *testing.T) { + vm, err := BuildCrossRepoViewModel(primaryWithDependents()) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + + got, err := NewMermaidEmitter().Emit(vm, DefaultTheme, WithTitle("cross-repo")) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + // Structural anchors a reader relies on: a flowchart with a subgraph per + // repo and the cross-repo edges in both directions. + for _, want := range []string{ + "flowchart TD", + `subgraph primary["primary"]`, + `subgraph repo_org_cdk_infra["org/cdk-infra"]`, + `subgraph repo_org_web_edge["org/web-edge"]`, + `subgraph repo_org_platform["org/platform"]`, + "==>|cdk|", + "==>|edge|", + "-. notify .->", + "end", + } { + if !strings.Contains(got, want) { + t.Errorf("emitted cross-repo Mermaid missing %q:\n%s", want, got) + } + } + + golden := filepath.Join("testdata", "crossrepo.mmd") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + wantBytes, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if got != string(wantBytes) { + t.Errorf("emitted cross-repo Mermaid does not match golden.\n--- got ---\n%s\n--- want ---\n%s", got, wantBytes) + } +} + +func TestMermaidEmitter_CrossRepoDeterministic(t *testing.T) { + build := func() (string, error) { + vm, err := BuildCrossRepoViewModel(primaryWithDependents()) + if err != nil { + return "", err + } + return NewMermaidEmitter().Emit(vm, DefaultTheme) + } + first, err := build() + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + next, err := build() + if err != nil { + t.Fatalf("emit %d: %v", i, err) + } + if next != first { + t.Fatalf("emit %d differs from first run", i) + } + } +} diff --git a/internal/visualize/mermaid.go b/internal/visualize/mermaid.go index a60b616f..87cf359c 100644 --- a/internal/visualize/mermaid.go +++ b/internal/visualize/mermaid.go @@ -53,14 +53,8 @@ func (MermaidEmitter) Emit(vm ViewModel, theme Theme, opts ...Option) (string, e // edges in model order, then the theme's class styling, so dependency or stage // flow reads in a stable order and a reader can tell the node kinds apart. func emitFlowchart(b *strings.Builder, vm ViewModel, theme Theme) (string, error) { - b.WriteString("flowchart TD\n") - - for _, n := range vm.Nodes { - // Node shape per kind: stadium for validate, rectangle for build, - // subroutine for deploy, rounded for a stage. The label is bracket-escaped - // so a display name with brackets cannot terminate the node early. - open, close := nodeBrackets(n.Kind) - fmt.Fprintf(b, " %s%s%s%s\n", mermaidID(n.ID), open, mermaidLabel(n.Label), close) + if err := writeFlowchartNodes(b, vm); err != nil { + return "", err } for _, e := range vm.Edges { @@ -70,6 +64,14 @@ func emitFlowchart(b *strings.Builder, vm ViewModel, theme Theme) (string, error fmt.Fprintf(b, " %s -.-> %s\n", mermaidID(e.From), mermaidID(e.To)) case EdgeHard, EdgeStage: fmt.Fprintf(b, " %s --> %s\n", mermaidID(e.From), mermaidID(e.To)) + case EdgeExternal: + // Thick labeled arrow marks the primary coordinating a dependent repo, + // styled distinctly from the intra-repo solid and dotted edges. + writeLabeledEdge(b, "%s ==> %s", "%s ==>|%s| %s", e) + case EdgeNotify: + // Dotted arrow with an inline caption marks a satellite notifying its + // primary, reading back up the cross-repo flow. + writeLabeledEdge(b, "%s -.-> %s", "%s -. %s .-> %s", e) default: return "", fmt.Errorf("visualize: mermaid: unknown flowchart edge kind %q", e.Kind) } @@ -80,6 +82,67 @@ func emitFlowchart(b *strings.Builder, vm ViewModel, theme Theme) (string, error return b.String(), nil } +// writeFlowchartNodes declares the model's nodes. When the model carries groups, +// grouped nodes are wrapped in a Mermaid subgraph per lane (in group order, then +// member order) and any ungrouped node is declared after the lanes; without +// groups every node is declared flat in model order, matching the original +// single-repo flowchart output. +func writeFlowchartNodes(b *strings.Builder, vm ViewModel) error { + b.WriteString("flowchart TD\n") + + if len(vm.Groups) == 0 { + for _, n := range vm.Nodes { + writeFlowchartNode(b, n, " ") + } + return nil + } + + byID := make(map[string]Node, len(vm.Nodes)) + for _, n := range vm.Nodes { + byID[n.ID] = n + } + + grouped := make(map[string]bool, len(vm.Nodes)) + for _, g := range vm.Groups { + fmt.Fprintf(b, " subgraph %s[%s]\n", mermaidID(g.ID), mermaidSubgraphLabel(g.Label)) + for _, id := range g.NodeIDs { + n, ok := byID[id] + if !ok { + return fmt.Errorf("visualize: mermaid: group %q references unknown node %q", g.ID, id) + } + writeFlowchartNode(b, n, " ") + grouped[id] = true + } + b.WriteString(" end\n") + } + + for _, n := range vm.Nodes { + if !grouped[n.ID] { + writeFlowchartNode(b, n, " ") + } + } + return nil +} + +// writeFlowchartNode declares one node with the shape its kind selects, indented +// by indent (deeper inside a subgraph). The label is escaped so a display name +// with brackets cannot terminate the node early. +func writeFlowchartNode(b *strings.Builder, n Node, indent string) { + open, close := nodeBrackets(n.Kind) + fmt.Fprintf(b, "%s%s%s%s%s\n", indent, mermaidID(n.ID), open, mermaidLabel(n.Label), close) +} + +// writeLabeledEdge emits a flowchart edge, choosing the plain format when the +// edge has no caption and the labeled format otherwise, so cross-repo edges read +// with their deploy or notify caption without a stray empty label segment. +func writeLabeledEdge(b *strings.Builder, plain, labeled string, e Edge) { + if e.Label == "" { + fmt.Fprintf(b, " "+plain+"\n", mermaidID(e.From), mermaidID(e.To)) + return + } + fmt.Fprintf(b, " "+labeled+"\n", mermaidID(e.From), mermaidEdgeLabel(e.Label), mermaidID(e.To)) +} + // emitState renders a state-machine model (the env projection) as a // stateDiagram-v2. Start and end markers render as Mermaid's [*] pseudo-state // rather than declared states; every other node is declared with a renamed @@ -230,6 +293,28 @@ func mermaidTransitionLabel(label string) string { return r.Replace(label) } +// mermaidSubgraphLabel renders a lane caption as a quoted Mermaid string for the +// `subgraph id["label"]` form, escaping any embedded quote so a repo name with +// punctuation cannot break the subgraph header. +func mermaidSubgraphLabel(label string) string { + return `"` + strings.ReplaceAll(label, `"`, """) + `"` +} + +// mermaidEdgeLabel sanitizes a flowchart edge caption. A pipe would close the +// `|label|` segment early and brackets or quotes confuse the parser, so each is +// folded to an entity Mermaid renders verbatim, keeping the caption on one line. +func mermaidEdgeLabel(label string) string { + r := strings.NewReplacer( + "|", "|", + "[", "[", + "]", "]", + `"`, """, + "\n", " ", + "\r", " ", + ) + return r.Replace(label) +} + // mermaidLabel escapes a display label for use inside a Mermaid node shape. // Brackets would otherwise close the shape early and quotes would confuse the // parser, so both are replaced with HTML entities Mermaid renders verbatim. diff --git a/internal/visualize/stages.go b/internal/visualize/stages.go index edd109eb..9e59d6d8 100644 --- a/internal/visualize/stages.go +++ b/internal/visualize/stages.go @@ -19,8 +19,32 @@ func BuildStagesViewModel(cfg *config.TrunkConfig) (ViewModel, error) { return ViewModel{}, fmt.Errorf("visualize: nil config") } - // Candidate stages in lifecycle order, each gated on whether the manifest - // actually exercises it. Trunk and release are unconditional bookends. + vm := ViewModel{Kind: DiagramFlowchart} + + var prev string + for _, c := range presentStages(cfg) { + vm.Nodes = append(vm.Nodes, Node{ID: c.id, Label: c.label, Kind: NodeStage}) + if prev != "" { + vm.Edges = append(vm.Edges, Edge{From: prev, To: c.id, Kind: EdgeStage}) + } + prev = c.id + } + + return vm, nil +} + +// stageDef is one coarse lifecycle stage with its display label. +type stageDef struct { + id string + label string +} + +// presentStages returns the lifecycle stages the manifest exercises, in order. +// Trunk and release are unconditional bookends; build, deploy, and promote are +// each gated on whether the manifest declares the corresponding work. The cross- +// repo projection reuses it to render the primary repo's pipeline lane, so the +// stage gating stays defined in one place. +func presentStages(cfg *config.TrunkConfig) []stageDef { candidates := []struct { id string label string @@ -33,19 +57,11 @@ func BuildStagesViewModel(cfg *config.TrunkConfig) (ViewModel, error) { {id: "release", label: "Release", present: true}, } - vm := ViewModel{Kind: DiagramFlowchart} - - var prev string + var stages []stageDef for _, c := range candidates { - if !c.present { - continue - } - vm.Nodes = append(vm.Nodes, Node{ID: c.id, Label: c.label, Kind: NodeStage}) - if prev != "" { - vm.Edges = append(vm.Edges, Edge{From: prev, To: c.id, Kind: EdgeStage}) + if c.present { + stages = append(stages, stageDef{id: c.id, label: c.label}) } - prev = c.id } - - return vm, nil + return stages } diff --git a/internal/visualize/testdata/crossrepo.mmd b/internal/visualize/testdata/crossrepo.mmd new file mode 100644 index 00000000..ad21005e --- /dev/null +++ b/internal/visualize/testdata/crossrepo.mmd @@ -0,0 +1,34 @@ +--- +title: "cross-repo" +--- +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#57606a"}}}%% +flowchart TD + subgraph primary["primary"] + trunk(Trunk) + build(Build) + deploy(Deploy) + promote(Promote) + release(Release) + end + subgraph repo_org_cdk_infra["org/cdk-infra"] + ext_org_cdk_infra_cdk[[cdk]] + end + subgraph repo_org_web_edge["org/web-edge"] + ext_org_web_edge_edge[[edge]] + end + subgraph repo_org_platform["org/platform"] + notify_org_platform[org/platform] + end + trunk --> build + build --> deploy + deploy --> promote + promote --> release + release ==>|cdk| ext_org_cdk_infra_cdk + release ==>|edge| ext_org_web_edge_edge + release -. notify .-> notify_org_platform + classDef node_stage fill:#bf8700,stroke:#7d4e00,color:#ffffff + classDef node_deploy fill:#8250df,stroke:#512a97,color:#ffffff + classDef node_repo fill:#6e7781,stroke:#424a53,color:#ffffff + class trunk,build,deploy,promote,release node_stage + class ext_org_cdk_infra_cdk,ext_org_web_edge_edge node_deploy + class notify_org_platform node_repo diff --git a/internal/visualize/theme.go b/internal/visualize/theme.go index db2c6c7b..c10888ea 100644 --- a/internal/visualize/theme.go +++ b/internal/visualize/theme.go @@ -59,6 +59,7 @@ var CascadeTheme = Theme{ NodeStage: {Fill: "#bf8700", Stroke: "#7d4e00", Text: "#ffffff"}, NodeEnv: {Fill: "#1f6feb", Stroke: "#0b3d91", Text: "#ffffff"}, NodeHotfix: {Fill: "#cf222e", Stroke: "#82071e", Text: "#ffffff"}, + NodeRepo: {Fill: "#6e7781", Stroke: "#424a53", Text: "#ffffff"}, }, } @@ -76,6 +77,7 @@ var BlandTheme = Theme{ NodeStage: {Fill: "#eaeef2", Stroke: "#6e7781", Text: "#24292f"}, NodeEnv: {Fill: "#f6f8fa", Stroke: "#6e7781", Text: "#24292f"}, NodeHotfix: {Fill: "#d0d7de", Stroke: "#57606a", Text: "#24292f"}, + NodeRepo: {Fill: "#d0d7de", Stroke: "#57606a", Text: "#24292f"}, }, } diff --git a/internal/visualize/viewmodel.go b/internal/visualize/viewmodel.go index f8425af0..0a4c1e9a 100644 --- a/internal/visualize/viewmodel.go +++ b/internal/visualize/viewmodel.go @@ -49,6 +49,10 @@ const ( // NodeEnd marks the state-machine terminal. An emitter renders it as the // renderer's final pseudo-state rather than a declared node. NodeEnd NodeKind = "end" + // NodeRepo is another repository rendered as an opaque node in the cross-repo + // projection, used for an upstream primary a satellite notifies or a + // dependent repo that declares no individual deployables. + NodeRepo NodeKind = "repo" ) // EdgeKind classifies an edge. Hard edges come from Edges (they both order a job @@ -73,6 +77,13 @@ const ( // EdgeTransition is an unlabeled state-machine transition, used for the // start and end bookend edges. EdgeTransition EdgeKind = "transition" + // EdgeExternal is the cross-repo coordination edge from the primary's + // pipeline to a dependent satellite's deployable, labeled with the deploy + // the primary drives. + EdgeExternal EdgeKind = "external" + // EdgeNotify is the cross-repo notify edge from a satellite's pipeline back to + // the primary it informs after a dev deploy. + EdgeNotify EdgeKind = "notify" ) // Node is one pipeline job in the view. ID is the stable, prefixed job ID @@ -96,14 +107,29 @@ type Edge struct { Label string } +// Group is a named lane that visually clusters a subset of nodes, used by the +// cross-repo projection to draw one lane per repository. ID is the stable lane +// identity, Label is the human-facing caption, and NodeIDs lists the member node +// IDs in render order. An emitter that has no notion of grouping (or a model with +// no groups) ignores it, so grouping is additive and never reshapes the existing +// flat projections. +type Group struct { + ID string + Label string + NodeIDs []string +} + // ViewModel is the deterministic, render-agnostic description of one pipeline // projection. Kind tells the emitter which diagram family to render; Nodes and // edges follow a stable construction order so two builds of the same manifest -// produce byte-identical emitter output. The model holds no diagram syntax. +// produce byte-identical emitter output. Groups, when present, cluster nodes into +// named lanes (the cross-repo projection draws one lane per repo); a model with +// no groups renders flat as before. The model holds no diagram syntax. type ViewModel struct { - Kind DiagramKind - Nodes []Node - Edges []Edge + Kind DiagramKind + Nodes []Node + Edges []Edge + Groups []Group } // BuildViewModel projects a generated DependencyGraph into a render-agnostic