Skip to content
Open
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
35 changes: 29 additions & 6 deletions cmd/topology_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{}
Expand All @@ -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{}
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions cmd/topology_test.go
Original file line number Diff line number Diff line change
@@ -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:
Expand Down