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
49 changes: 39 additions & 10 deletions internal/status/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ func (r *Resolver) ResolveRepo(ctx context.Context, spec *Spec, repo RepoInfo, w

// ResolveWorkspace resolves the status for each repo concurrently and
// returns the aggregated workspace result. The workspace status is the
// least-advanced status (highest index in spec order) across all repos.
// least-advanced status (highest index in spec order) across all repos,
// excluding skipped repos and repos at the default status.
func (r *Resolver) ResolveWorkspace(ctx context.Context, spec *Spec, repos []RepoInfo, wsID, wsName string) *WorkspaceResult {
wsStart := time.Now()
result := &WorkspaceResult{
Expand All @@ -91,18 +92,24 @@ func (r *Resolver) ResolveWorkspace(ctx context.Context, spec *Spec, repos []Rep
Repos: make([]RepoResult, len(repos)),
}

// Find the default status index.
defaultIdx := -1
for i, e := range spec.Spec.Statuses {
if e.Default {
defaultIdx = i
break
}
}

if len(repos) == 0 {
// No repos — use the default status.
for _, e := range spec.Spec.Statuses {
if e.Default {
result.Status = e.Name
break
}
if defaultIdx >= 0 {
result.Status = spec.Spec.Statuses[defaultIdx].Name
}
result.Duration = time.Since(wsStart)
return result
}

skipped := make([]bool, len(repos))
var mu sync.Mutex
var wg sync.WaitGroup

Expand All @@ -111,8 +118,17 @@ func (r *Resolver) ResolveWorkspace(ctx context.Context, spec *Spec, repos []Rep
go func(idx int, rp RepoInfo) {
defer wg.Done()
repoStart := time.Now()
env := buildEnv(rp, wsID, wsName)

// Run skip check if configured.
skip := false
if spec.Spec.Skip != "" {
skip = r.Runner.RunCheck(ctx, spec.Spec.Skip, env)
}

status := r.ResolveRepo(ctx, spec, rp, wsID, wsName)
mu.Lock()
skipped[idx] = skip
result.Repos[idx] = RepoResult{
URL: rp.URL,
Branch: rp.Branch,
Expand All @@ -132,16 +148,29 @@ func (r *Resolver) ResolveWorkspace(ctx context.Context, spec *Spec, repos []Rep
orderIndex[e.Name] = i
}

// Workspace status = least advanced (highest index) across repos.
// Workspace status = least advanced (highest index) across repos,
// excluding skipped repos and repos at the default status.
worstIdx := -1
for _, rr := range result.Repos {
for i, rr := range result.Repos {
if skipped[i] {
continue
}
idx, ok := orderIndex[rr.Status]
if ok && idx > worstIdx {
if !ok {
continue
}
if idx == defaultIdx {
continue
}
if idx > worstIdx {
worstIdx = idx
}
}

if worstIdx >= 0 {
result.Status = spec.Spec.Statuses[worstIdx].Name
} else if defaultIdx >= 0 {
result.Status = spec.Spec.Statuses[defaultIdx].Name
}

return result
Expand Down
108 changes: 108 additions & 0 deletions internal/status/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,114 @@ func TestRepoSlug(t *testing.T) {
}
}

func testSpecWithSkip() *Spec {
s := testSpec()
s.Spec.Skip = "check-skip"
return s
}

func TestResolveWorkspaceSkippedRepoExcluded(t *testing.T) {
// repo-a is in-progress (feature branch), repo-b is open (main, skipped).
// Without skip, workspace would be open. With skip, repo-b excluded → in-progress.
perRepoMock := &perRepoRunner{
results: map[string]map[string]bool{
"github.com/org/repo-a": {"check-progress": true},
"github.com/org/repo-b": {"check-skip": true},
},
}
resolver := &Resolver{Runner: perRepoMock}

repos := []RepoInfo{
{URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
{URL: "github.com/org/repo-b", Branch: "main", Path: "./repo-b"},
}
result := resolver.ResolveWorkspace(context.Background(), testSpecWithSkip(), repos, "ws-1", "my-ws")

if result.Status != "in-progress" {
t.Errorf("workspace status = %q, want in-progress (skipped repo excluded)", result.Status)
}
}

func TestResolveWorkspaceDefaultStatusPassive(t *testing.T) {
// Two feature repos: one closed, one at default (open).
// Default status is passive — should not drag workspace to open.
perRepoMock := &perRepoRunner{
results: map[string]map[string]bool{
"github.com/org/repo-a": {"check-closed": true},
"github.com/org/repo-b": {},
},
}
resolver := &Resolver{Runner: perRepoMock}

repos := []RepoInfo{
{URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
{URL: "github.com/org/repo-b", Branch: "feat/y", Path: "./repo-b"},
}
result := resolver.ResolveWorkspace(context.Background(), testSpec(), repos, "ws-1", "my-ws")

if result.Status != "closed" {
t.Errorf("workspace status = %q, want closed (default status passive)", result.Status)
}
}

func TestResolveWorkspaceAllSkippedFallback(t *testing.T) {
// All repos skipped → fall back to default status.
mock := &mockRunner{results: map[string]bool{
"check-skip": true,
}}
resolver := &Resolver{Runner: mock}

repos := []RepoInfo{
{URL: "github.com/org/repo-a", Branch: "main", Path: "./repo-a"},
{URL: "github.com/org/repo-b", Branch: "main", Path: "./repo-b"},
}
result := resolver.ResolveWorkspace(context.Background(), testSpecWithSkip(), repos, "ws-1", "my-ws")

if result.Status != "open" {
t.Errorf("workspace status = %q, want open (all skipped fallback)", result.Status)
}
}

func TestResolveWorkspaceMixedSkipAndDefault(t *testing.T) {
// repo-a: closed (feat), repo-b: in-review (feat), repo-c: open (main, skipped).
// Skip excludes repo-c, default excludes nothing extra. Least advanced = in-review.
perRepoMock := &perRepoRunner{
results: map[string]map[string]bool{
"github.com/org/repo-a": {"check-closed": true},
"github.com/org/repo-b": {"check-review": true},
"github.com/org/repo-c": {"check-skip": true},
},
}
resolver := &Resolver{Runner: perRepoMock}

repos := []RepoInfo{
{URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
{URL: "github.com/org/repo-b", Branch: "feat/y", Path: "./repo-b"},
{URL: "github.com/org/repo-c", Branch: "main", Path: "./repo-c"},
}
result := resolver.ResolveWorkspace(context.Background(), testSpecWithSkip(), repos, "ws-1", "my-ws")

if result.Status != "in-review" {
t.Errorf("workspace status = %q, want in-review (mixed skip and default)", result.Status)
}
}

func TestResolveWorkspaceAllFeatureAtDefault(t *testing.T) {
// All feature-branch repos at default status → default (no active signal).
mock := &mockRunner{results: map[string]bool{}}
resolver := &Resolver{Runner: mock}

repos := []RepoInfo{
{URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
{URL: "github.com/org/repo-b", Branch: "feat/y", Path: "./repo-b"},
}
result := resolver.ResolveWorkspace(context.Background(), testSpec(), repos, "ws-1", "my-ws")

if result.Status != "open" {
t.Errorf("workspace status = %q, want open (all at default)", result.Status)
}
}

// perRepoRunner distinguishes check results by repo URL (extracted from env).
type perRepoRunner struct {
results map[string]map[string]bool // repoURL -> command -> result
Expand Down
2 changes: 2 additions & 0 deletions internal/status/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ func DefaultSpec() *Spec {
APIVersion: "flow/v1",
Kind: "Status",
Spec: SpecBody{
Skip: `_default=$(git -C "$FLOW_REPO_PATH" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')
[ -n "$_default" ] && [ "$FLOW_REPO_BRANCH" = "$_default" ]`,
Statuses: []Entry{
{
Name: "closed",
Expand Down
1 change: 1 addition & 0 deletions internal/status/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "time"

// SpecBody holds the statuses list nested under spec.
type SpecBody struct {
Skip string `yaml:"skip,omitempty"`
Statuses []Entry `yaml:"statuses,omitempty"`
}

Expand Down