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
16 changes: 11 additions & 5 deletions cmd/nightshift/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,6 @@ func runRun(cmd *cobra.Command, args []string) error {
return fmt.Errorf("resolve projects: %w", err)
}

// Limit projects when --project was not explicitly set
if projectPath == "" && maxProjects > 0 && len(projects) > maxProjects {
projects = projects[:maxProjects]
}

if len(projects) == 0 {
fmt.Println("no projects configured")
return nil
Expand Down Expand Up @@ -251,6 +246,7 @@ func runRun(cmd *cobra.Command, args []string) error {
st: st,
projects: projects,
taskFilter: taskFilter,
maxProjects: maxProjects,
maxTasks: maxTasks,
randomTask: randomTask,
ignoreBudget: ignoreBudget,
Expand All @@ -273,6 +269,7 @@ type executeRunParams struct {
st *state.State
projects []string
taskFilter string
maxProjects int
maxTasks int
randomTask bool
ignoreBudget bool
Expand Down Expand Up @@ -438,7 +435,13 @@ func buildPreflight(p executeRunParams) (*preflightPlan, error) {
branch: p.branch,
}

eligibleCount := 0
for _, projectPath := range p.projects {
// Apply --max-projects limit (counts only eligible, non-skipped projects)
if p.maxProjects > 0 && eligibleCount >= p.maxProjects {
break
}

// Skip if already processed today (unless task filter specified)
if p.taskFilter == "" && p.st.WasProcessedToday(projectPath) {
p.log.Infof("skip %s (processed today)", projectPath)
Expand Down Expand Up @@ -513,6 +516,9 @@ func buildPreflight(p executeRunParams) (*preflightPlan, error) {
}

plan.projects = append(plan.projects, pp)
if pp.skipReason == "" {
eligibleCount++
}
}

return plan, nil
Expand Down
90 changes: 51 additions & 39 deletions cmd/nightshift/commands/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,56 +242,68 @@ func newTestRunConfig() *config.Config {
}
}

func TestMaxProjects_DefaultLimitsToOne(t *testing.T) {
// Simulate 3 projects, no --project set, maxProjects=1 (default)
projects := []string{"/proj/a", "/proj/b", "/proj/c"}
projectPath := "" // not explicitly set
maxProjects := 1
// TestMaxProjects_SkipsProcessedBeforeCounting verifies that --max-projects counts
// only eligible (non-processed-today) projects. With project[0] already processed
// and maxProjects=1, project[1] should be the one that gets processed.
func TestMaxProjects_SkipsProcessedBeforeCounting(t *testing.T) {
p0 := t.TempDir()
p1 := t.TempDir()
p2 := t.TempDir()
params := newPreflightParams(t, []string{p0, p1, p2})
params.maxProjects = 1

// Mark p0 as already processed today
params.st.RecordProjectRun(p0)

if projectPath == "" && maxProjects > 0 && len(projects) > maxProjects {
projects = projects[:maxProjects]
}

if len(projects) != 1 {
t.Fatalf("len(projects) = %d, want 1", len(projects))
}
if projects[0] != "/proj/a" {
t.Fatalf("projects[0] = %q, want /proj/a", projects[0])
plan, err := buildPreflight(params)
if err != nil {
t.Fatalf("buildPreflight: %v", err)
}
}

func TestMaxProjects_OverrideToN(t *testing.T) {
projects := []string{"/proj/a", "/proj/b", "/proj/c"}
projectPath := ""
maxProjects := 2

if projectPath == "" && maxProjects > 0 && len(projects) > maxProjects {
projects = projects[:maxProjects]
// p0 should be skipped (processed today), p1 should be eligible and counted,
// p2 should not be reached (limit hit).
eligibleCount := 0
var eligiblePath string
for _, pp := range plan.projects {
if pp.skipReason == "" {
eligibleCount++
eligiblePath = pp.path
}
}

if len(projects) != 2 {
t.Fatalf("len(projects) = %d, want 2", len(projects))
if eligibleCount != 1 {
t.Fatalf("eligible projects = %d, want 1", eligibleCount)
}
if projects[1] != "/proj/b" {
t.Fatalf("projects[1] = %q, want /proj/b", projects[1])
if eligiblePath != p1 {
t.Fatalf("eligible project = %q, want p1 (%q)", eligiblePath, p1)
}
}

func TestMaxProjects_IgnoredWhenProjectSet(t *testing.T) {
projects := []string{"/proj/explicit"}
projectPath := "/proj/explicit" // explicitly set
maxProjects := 1
// TestMaxProjects_LimitsEligibleCount verifies that with no projects processed,
// --max-projects 2 processes exactly 2 projects.
func TestMaxProjects_LimitsEligibleCount(t *testing.T) {
p0 := t.TempDir()
p1 := t.TempDir()
p2 := t.TempDir()
params := newPreflightParams(t, []string{p0, p1, p2})
params.maxProjects = 2

// The guard: projectPath == "" is false, so no truncation
if projectPath == "" && maxProjects > 0 && len(projects) > maxProjects {
projects = projects[:maxProjects]
plan, err := buildPreflight(params)
if err != nil {
t.Fatalf("buildPreflight: %v", err)
}

if len(projects) != 1 {
t.Fatalf("len(projects) = %d, want 1", len(projects))
eligibleCount := 0
for _, pp := range plan.projects {
if pp.skipReason == "" {
eligibleCount++
}
}
if eligibleCount != 2 {
t.Fatalf("eligible projects = %d, want 2", eligibleCount)
}
if projects[0] != "/proj/explicit" {
t.Fatalf("projects[0] = %q, want /proj/explicit", projects[0])
// Total projects in plan should be 2 (p2 never added)
if len(plan.projects) != 2 {
t.Fatalf("plan.projects len = %d, want 2", len(plan.projects))
}
}

Expand Down Expand Up @@ -1257,7 +1269,7 @@ func TestDisplayPreflight_NoWarningsWhenBudgetRespected(t *testing.T) {
func TestScheduleMaxProjectsFromConfig(t *testing.T) {
// Simulate what runRun does: after loading config, apply schedule.MaxProjects
// when the flag was not explicitly changed by the user.
maxProjects := 1 // CLI default
maxProjects := 1 // CLI default
maxProjectsChanged := false // --max-projects was NOT passed

cfg := &config.Config{
Expand Down
8 changes: 4 additions & 4 deletions internal/agents/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ import (
// - Standalone: npm install -g @github/copilot or curl script
// - Usage: copilot -p "<prompt>" --no-ask-user --silent
type CopilotAgent struct {
binaryPath string // Path to binary: "gh" or "copilot" (default: "gh")
dangerouslySkipPerms bool // Pass --allow-all-tools --allow-all-urls
timeout time.Duration // Default timeout
runner CommandRunner // Command executor (for testing)
binaryPath string // Path to binary: "gh" or "copilot" (default: "gh")
dangerouslySkipPerms bool // Pass --allow-all-tools --allow-all-urls
timeout time.Duration // Default timeout
runner CommandRunner // Command executor (for testing)
}

// CopilotOption configures a CopilotAgent.
Expand Down
Loading