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
61 changes: 61 additions & 0 deletions cmd/cascade/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,67 @@ func TestGraphCommand_MissingManifest(t *testing.T) {
}
}

// graphEnvManifestContent is a two-environment manifest whose dev env state
// tracks a hotfix integration branch, so the env projection must render the
// promotion state machine with a divergence branch that rejoins at prod.
const graphEnvManifestContent = `ci:
config:
trunk_branch: main
environments:
- dev
- prod
state:
dev:
ref: hotfix/patch
prod: {}
`

func writeGraphEnvManifest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
if err := os.WriteFile(path, []byte(graphEnvManifestContent), 0644); err != nil {
t.Fatalf("Failed to write manifest: %v", err)
}
return path
}

func TestGraphCommand_EnvGranularityEmitsStateMachine(t *testing.T) {
manifest := writeGraphEnvManifest(t)
stdout, stderr, err := runCLI("graph", "--config", manifest, "--granularity", "env")
if err != nil {
t.Fatalf("graph --granularity env failed: %v\nstderr: %s", err, stderr)
}
for _, want := range []string{
"stateDiagram-v2",
"[*] --> dev",
"dev --> prod : promote",
"dev --> dev_hotfix : diverge",
"dev_hotfix --> prod : rejoin",
} {
if !contains(stdout, want) {
t.Errorf("expected %q in env projection, got:\n%s", want, stdout)
}
}
}

func TestGraphCommand_StagesGranularityEmitsFlowchart(t *testing.T) {
manifest := writeGraphManifest(t)
stdout, stderr, err := runCLI("graph", "--config", manifest, "--granularity", "stages")
if err != nil {
t.Fatalf("graph --granularity stages failed: %v\nstderr: %s", err, stderr)
}
for _, want := range []string{"flowchart TD", "trunk", "build", "deploy", "promote", "release"} {
if !contains(stdout, want) {
t.Errorf("expected %q in stages projection, got:\n%s", want, stdout)
}
}
// The stage rollup must not carry per-callback job nodes.
if contains(stdout, "build_app") || contains(stdout, "deploy_app") {
t.Errorf("stages projection leaked per-callback nodes:\n%s", stdout)
}
}

// -------- status command integration tests --------

// fixtureManifestPath returns the path to the on-disk status fixture.
Expand Down
14 changes: 8 additions & 6 deletions internal/graph/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ func NewCommand() *cobra.Command {
Short: "Render the generated pipeline as a Mermaid diagram",
Long: `Render the manifest's generated pipeline as a diagram on stdout.

graph loads the manifest, builds the same job dependency graph the generator
uses, and emits it as Mermaid that GitHub renders natively in Markdown. Pipe the
output into a file or paste it into a README or pull request to show how the
pipeline's jobs depend on one another. Hard dependencies render as solid arrows
and optional ordering-only dependencies as dotted arrows.
graph loads the manifest and emits Mermaid that GitHub renders natively in
Markdown. Pipe the output into a file or paste it into a README or pull request.
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.

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 @@ -38,7 +40,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 value: jobs")
cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported values: jobs, stages, env")
cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported value: mermaid")
cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme; supported value: default")

Expand Down
68 changes: 51 additions & 17 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import (
"github.com/stablekernel/cascade/internal/visualize"
)

// Granularity selects which projection of the pipeline the graph renders. The
// job DAG is the only projection that exists today; env and stage rollups are
// recognized values that Run rejects with a clear message until their
// projections land, so the flag contract is stable as the projections grow.
// Granularity selects which projection of the pipeline the graph renders. Each
// value maps to a distinct view-model build feeding the same emitter: jobs is
// the full job DAG, stages is the coarse lifecycle flow, and env is the
// promotion state machine with its hotfix divergence and rejoin.
type Granularity string

// Granularity values. Only GranularityJobs produces a diagram today.
// Granularity values.
const (
GranularityJobs Granularity = "jobs"
GranularityStages Granularity = "stages"
Expand Down Expand Up @@ -79,12 +79,11 @@ func Run(o Options, stdout io.Writer) error {
granularity = string(GranularityJobs)
}
switch Granularity(granularity) {
case GranularityJobs:
// The job DAG is the implemented projection.
case GranularityStages, GranularityEnv:
return fmt.Errorf("granularity %q is not yet available: only %q is supported", granularity, GranularityJobs)
case GranularityJobs, GranularityStages, GranularityEnv:
// Each granularity maps to a supported projection.
default:
return fmt.Errorf("unknown granularity %q: supported value is %q", granularity, GranularityJobs)
return fmt.Errorf("unknown granularity %q: supported values are %q, %q, and %q",
granularity, GranularityJobs, GranularityStages, GranularityEnv)
}

theme := o.Theme
Expand All @@ -104,14 +103,9 @@ func Run(o Options, stdout io.Writer) error {
key = config.DefaultManifestKey
}

cfg, err := config.ParseWithKey(configPath, key)
vm, err := buildView(Granularity(granularity), configPath, key)
if err != nil {
return fmt.Errorf("loading manifest: %w", err)
}

vm, err := visualize.BuildViewModel(generate.BuildDependencyGraph(cfg))
if err != nil {
return fmt.Errorf("building graph view: %w", err)
return err
}

diagram, err := visualize.NewMermaidEmitter().Emit(vm, visualize.DefaultTheme)
Expand All @@ -131,6 +125,46 @@ func Run(o Options, stdout io.Writer) error {
return nil
}

// buildView loads the manifest and projects it into the view model the chosen
// granularity calls for. The jobs and stages projections need only the pipeline
// config; the env projection also needs the per-environment state so it can draw
// hotfix divergence, so it loads the full manifest file rather than just the
// config section.
func buildView(granularity Granularity, configPath, key string) (visualize.ViewModel, error) {
switch granularity {
case GranularityEnv:
file, err := config.ParseManifestFile(configPath, key)
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err)
}
vm, err := visualize.BuildEnvViewModel(file.Config, file.State)
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("building env view: %w", err)
}
return vm, nil
case GranularityStages:
cfg, err := config.ParseWithKey(configPath, key)
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err)
}
vm, err := visualize.BuildStagesViewModel(cfg)
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("building stages view: %w", err)
}
return vm, nil
default:
cfg, err := config.ParseWithKey(configPath, key)
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err)
}
vm, err := visualize.BuildViewModel(generate.BuildDependencyGraph(cfg))
if err != nil {
return visualize.ViewModel{}, fmt.Errorf("building graph view: %w", err)
}
return vm, nil
}
}

// writeJSON emits the diagram wrapped in a structured envelope so a caller can
// consume the format, granularity, theme, and diagram source together.
func writeJSON(stdout io.Writer, format, granularity, theme, diagram string) error {
Expand Down
62 changes: 52 additions & 10 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ func writeManifest(t *testing.T) string {
return path
}

// writeDivergedManifest lays down a two-environment manifest whose dev env state
// tracks a hotfix integration branch, so the env projection must draw a
// divergence branch off dev that rejoins at prod.
func writeDivergedManifest(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"},
}
manifest := map[string]any{
config.DefaultManifestKey: config.CICDFile{
Config: cfg,
State: map[string]*config.EnvState{
"dev": {Ref: "hotfix/patch"},
"prod": {},
},
},
}
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
}

// baseOptions returns valid options pointed at the given manifest, so each test
// can override the one field under exercise.
func baseOptions(manifestPath string) Options {
Expand Down Expand Up @@ -82,27 +110,41 @@ func TestRun_UnknownFormat_Errors(t *testing.T) {
require.Empty(t, out.String())
}

func TestRun_StagesGranularity_NotYetSupported(t *testing.T) {
func TestRun_StagesGranularity_EmitsFlowchart(t *testing.T) {
path := writeManifest(t)
o := baseOptions(path)
o.Granularity = string(GranularityStages)

var out bytes.Buffer
err := Run(o, &out)
require.Error(t, err)
require.Contains(t, err.Error(), "stages")
require.Contains(t, err.Error(), "jobs")
require.NoError(t, Run(o, &out))

got := out.String()
require.Contains(t, got, "flowchart TD")
// The stage rollup shows coarse stages, not the per-callback job nodes.
require.Contains(t, got, "trunk")
require.Contains(t, got, "build")
require.Contains(t, got, "deploy")
require.Contains(t, got, "promote")
require.Contains(t, got, "release")
require.NotContains(t, got, "build_app")
require.NotContains(t, got, "deploy_app")
}

func TestRun_EnvGranularity_NotYetSupported(t *testing.T) {
path := writeManifest(t)
func TestRun_EnvGranularity_EmitsStateMachine(t *testing.T) {
path := writeDivergedManifest(t)
o := baseOptions(path)
o.Granularity = string(GranularityEnv)

var out bytes.Buffer
err := Run(o, &out)
require.Error(t, err)
require.Contains(t, err.Error(), "env")
require.NoError(t, Run(o, &out))

got := out.String()
require.Contains(t, got, "stateDiagram-v2")
require.Contains(t, got, "[*] --> dev")
require.Contains(t, got, "dev --> prod : promote")
// The diverged dev env draws a hotfix branch that rejoins downstream.
require.Contains(t, got, "dev --> dev_hotfix : diverge")
require.Contains(t, got, "dev_hotfix --> prod : rejoin")
}

func TestRun_UnknownGranularity_Errors(t *testing.T) {
Expand Down
94 changes: 94 additions & 0 deletions internal/visualize/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package visualize

import (
"fmt"

"github.com/stablekernel/cascade/internal/config"
)

// startNodeID and endNodeID are the reserved identities of the state machine's
// entry and terminal pseudo-states. They never collide with an environment name
// because cascade env names are simple slugs; the emitter renders nodes of kind
// NodeStart and NodeEnd as the renderer's initial and final markers, so these
// IDs are model-internal and never appear in the diagram text.
const (
startNodeID = "__start__"
endNodeID = "__end__"
)

// BuildEnvViewModel projects the promotion ladder into a render-agnostic state
// machine. The configured environments become a linear chain of env states from
// the first environment to the last, bracketed by a start and an end marker.
// Each environment whose state has diverged onto an integration branch (a hotfix
// Ref or applied patches) gains a hotfix state that branches off that
// environment and rejoins the ladder at the next environment downstream, or at
// the terminal when the diverged environment is last. The model carries no
// diagram syntax; the emitter turns it into a state diagram.
func BuildEnvViewModel(cfg *config.TrunkConfig, state map[string]*config.EnvState) (ViewModel, error) {
if cfg == nil {
return ViewModel{}, fmt.Errorf("visualize: nil config")
}
if len(cfg.Environments) == 0 {
return ViewModel{}, fmt.Errorf("visualize: config declares no environments to render")
}

vm := ViewModel{Kind: DiagramState}

// Bookend markers frame the ladder. They are declared first so the start
// transition reads before the env states in the model.
vm.Nodes = append(vm.Nodes,
Node{ID: startNodeID, Kind: NodeStart},
Node{ID: endNodeID, Kind: NodeEnd},
)

for _, env := range cfg.Environments {
vm.Nodes = append(vm.Nodes, Node{ID: env, Label: env, Kind: NodeEnv})
}

// The entry transition lands on the first environment; the exit transition
// leaves the last one.
first := cfg.Environments[0]
last := cfg.Environments[len(cfg.Environments)-1]
vm.Edges = append(vm.Edges, Edge{From: startNodeID, To: first, Kind: EdgeTransition})

// Promotion transitions chain each environment to the next in order.
for i := 0; i+1 < len(cfg.Environments); i++ {
vm.Edges = append(vm.Edges, Edge{
From: cfg.Environments[i],
To: cfg.Environments[i+1],
Kind: EdgePromote,
Label: "promote",
})
}
vm.Edges = append(vm.Edges, Edge{From: last, To: endNodeID, Kind: EdgeTransition})

// Divergence branches are appended after the linear ladder so the chain reads
// top to bottom before the hotfix detours, keeping the model order stable.
for i, env := range cfg.Environments {
es := state[env]
if !es.IsDiverged() {
continue
}
hotfixID := env + "_hotfix"
vm.Nodes = append(vm.Nodes, Node{ID: hotfixID, Label: hotfixLabel(es), Kind: NodeHotfix})
vm.Edges = append(vm.Edges, Edge{From: env, To: hotfixID, Kind: EdgeDiverge, Label: "diverge"})

rejoinTo := endNodeID
if i+1 < len(cfg.Environments) {
rejoinTo = cfg.Environments[i+1]
}
vm.Edges = append(vm.Edges, Edge{From: hotfixID, To: rejoinTo, Kind: EdgeRejoin, Label: "rejoin"})
}

return vm, nil
}

// hotfixLabel names a divergence state. It prefers the integration branch ref so
// a reader sees which branch the environment tracks, and falls back to a generic
// label when the divergence is patch-only with no recorded ref.
func hotfixLabel(es *config.EnvState) string {
if es != nil && es.Ref != "" {
return es.Ref
}
return "hotfix"
}
Loading
Loading