diff --git a/cmd/topology_commands.go b/cmd/topology_commands.go index 9d64761..93ba7af 100644 --- a/cmd/topology_commands.go +++ b/cmd/topology_commands.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/url" + "os" + "path/filepath" "github.com/semaphoreio/sem-ai/pkg/client" "github.com/semaphoreio/sem-ai/pkg/config" @@ -315,6 +317,7 @@ func fetchTopology(pipelineID string) (map[string]interface{}, error) { // Try fetching YAML from workflow artifacts wfID := pplData.Pipeline.WfID var yamlDeps map[string][]string // block name → dependencies + yamlSource := "none" if wfID != "" { artParams := url.Values{} @@ -330,17 +333,26 @@ func fetchTopology(pipelineID string) (map[string]interface{}, error) { yamlResp, err := c.GetExternal(artData.URL) if err == nil && yamlResp.StatusCode == 200 { yamlDeps = parseYAMLDependencies(yamlResp.Body) + yamlSource = "artifact" } } } } - // 3. If still no deps, try to infer from block ordering (basic heuristic): - // blocks without explicit deps depend on nothing; otherwise use YAML data + // 3. Fallback: the pipeline definition isn't a job artifact, so the fetch + // above usually misses. When run from the repo (the common case), read + // the pipeline YAML straight off disk — that's where the block + // dependencies actually live. if yamlDeps == nil { - // Last resort: infer from execution order. Blocks that are "waiting" - // while others run are likely dependent. But this is imprecise. - // Return blocks without dependency info. + if deps := localPipelineDeps(workingDir, yamlFile); deps != nil { + yamlDeps = deps + yamlSource = "local" + } + } + + if yamlDeps == nil { + // No dependency source available — return blocks without edges + // (critical path / blast radius degrade to per-block, not a chain). yamlDeps = map[string][]string{} } @@ -363,7 +375,7 @@ func fetchTopology(pipelineID string) (map[string]interface{}, error) { "pipeline_id": pipelineID, "blocks": blocks, "total": len(blocks), - "source": "v1alpha+yaml", + "source": "v1alpha+" + yamlSource, } return result, nil @@ -397,6 +409,17 @@ func parseYAMLDependencies(yamlContent []byte) map[string][]string { return deps } +// localPipelineDeps reads the pipeline YAML from the local working tree +// (e.g. .semaphore/semaphore.yml) and extracts block dependencies. Returns +// nil if the file is missing or has no parseable blocks. +func localPipelineDeps(workingDir, yamlFile string) map[string][]string { + data, err := os.ReadFile(filepath.Join(workingDir, yamlFile)) + if err != nil { + return nil + } + return parseYAMLDependencies(data) +} + // topoBlocksFromMap extracts []blockTopology from the topology result map. func topoBlocksFromMap(topo map[string]interface{}) []blockTopology { // Try direct typed assertion first diff --git a/cmd/topology_test.go b/cmd/topology_test.go index a9b5e60..e1e81d8 100644 --- a/cmd/topology_test.go +++ b/cmd/topology_test.go @@ -1,9 +1,64 @@ package cmd import ( + "os" + "path/filepath" "testing" ) +// Regression: critical-path/topology returned every block with empty +// dependencies because fetchTopology only tried to fetch the pipeline YAML as +// a workflow artifact (which doesn't exist). It must also read the pipeline +// YAML from the local working tree, where the dependencies actually live. +func TestLocalPipelineDeps(t *testing.T) { + dir := t.TempDir() + semDir := filepath.Join(dir, ".semaphore") + if err := os.MkdirAll(semDir, 0o755); err != nil { + t.Fatal(err) + } + yaml := `version: v1.0 +blocks: + - name: Build + dependencies: [] + - name: Test + dependencies: ["Build"] + - name: Deploy + dependencies: ["Test"] +` + if err := os.WriteFile(filepath.Join(semDir, "semaphore.yml"), []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + deps := localPipelineDeps(semDir, "semaphore.yml") + if deps == nil { + t.Fatal("expected deps from local YAML, got nil") + } + if len(deps["Build"]) != 0 { + t.Errorf("Build deps = %v, want []", deps["Build"]) + } + if len(deps["Test"]) != 1 || deps["Test"][0] != "Build" { + t.Errorf("Test deps = %v, want [Build]", deps["Test"]) + } + if len(deps["Deploy"]) != 1 || deps["Deploy"][0] != "Test" { + t.Errorf("Deploy deps = %v, want [Test]", deps["Deploy"]) + } + + // The critical path through this chain is the full Build->Test->Deploy. + blocks := []blockTopology{ + {Name: "Build", Dependencies: deps["Build"]}, + {Name: "Test", Dependencies: deps["Test"]}, + {Name: "Deploy", Dependencies: deps["Deploy"]}, + } + if got := computeCriticalPath(blocks); len(got) != 3 { + t.Errorf("critical path = %v, want 3 blocks (Build->Test->Deploy)", got) + } + + // Missing file → nil (graceful). + if localPipelineDeps(filepath.Join(dir, "nope"), "semaphore.yml") != nil { + t.Error("expected nil for missing YAML") + } +} + func TestParseYAMLDependenciesBasic(t *testing.T) { deps := parseYAMLDependencies([]byte(` blocks: