Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions internal/graph/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand All @@ -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")

Expand Down
23 changes: 17 additions & 6 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions internal/visualize/crossrepo.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading