diff --git a/cmd/nightshift/commands/run.go b/cmd/nightshift/commands/run.go index 9681002..0551f7d 100644 --- a/cmd/nightshift/commands/run.go +++ b/cmd/nightshift/commands/run.go @@ -729,6 +729,10 @@ func executeRun(ctx context.Context, p executeRunParams) error { p.st.MarkAssigned(taskInstance.ID, projectPath, string(scoredTask.Definition.Type)) // Inject run metadata for PR traceability + draftPR := false + if pc := p.cfg.ProjectByPath(projectPath); pc != nil { + draftPR = pc.DraftPR + } orch.SetRunMetadata(&orchestrator.RunMetadata{ Provider: choice.name, TaskType: string(scoredTask.Definition.Type), @@ -736,6 +740,7 @@ func executeRun(ctx context.Context, p executeRunParams) error { CostTier: scoredTask.Definition.CostTier.String(), RunStart: projectStart, Branch: p.branch, + DraftPR: draftPR, }) // Execute via orchestrator diff --git a/cmd/nightshift/commands/task.go b/cmd/nightshift/commands/task.go index 6b1820e..26f8420 100644 --- a/cmd/nightshift/commands/task.go +++ b/cmd/nightshift/commands/task.go @@ -71,6 +71,7 @@ func init() { taskRunCmd.Flags().Bool("dry-run", false, "Show prompt without executing") taskRunCmd.Flags().Duration("timeout", 30*time.Minute, "Execution timeout") taskRunCmd.Flags().StringP("branch", "b", "", "Base branch for new feature branches (defaults to current branch)") + taskRunCmd.Flags().Bool("draft", false, "Open PRs as drafts") _ = taskRunCmd.MarkFlagRequired("provider") taskCmd.AddCommand(taskListCmd) @@ -183,6 +184,7 @@ func runTaskRun(cmd *cobra.Command, args []string) error { dryRun, _ := cmd.Flags().GetBool("dry-run") timeout, _ := cmd.Flags().GetDuration("timeout") branch, _ := cmd.Flags().GetString("branch") + draftPR, _ := cmd.Flags().GetBool("draft") def, err := tasks.GetDefinition(taskType) if err != nil { @@ -235,10 +237,17 @@ func runTaskRun(cmd *cobra.Command, args []string) error { ) // Inject run metadata with branch for prompt generation + // draft_pr: use --draft flag, or fall back to project config + if !draftPR { + if pc := cfg.ProjectByPath(projectPath); pc != nil { + draftPR = pc.DraftPR + } + } orch.SetRunMetadata(&orchestrator.RunMetadata{ Provider: provider, TaskType: string(taskType), Branch: branch, + DraftPR: draftPR, }) prompt := orch.PlanPrompt(taskInstance) diff --git a/internal/config/config.go b/internal/config/config.go index f0d7347..f229067 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,10 +82,11 @@ type ProviderConfig struct { type ProjectConfig struct { Path string `mapstructure:"path"` Priority int `mapstructure:"priority"` - Tasks []string `mapstructure:"tasks"` // Task overrides for this project - Config string `mapstructure:"config"` // Per-project config file - Pattern string `mapstructure:"pattern"` // Glob pattern for discovery - Exclude []string `mapstructure:"exclude"` // Paths to exclude + Tasks []string `mapstructure:"tasks"` // Task overrides for this project + Config string `mapstructure:"config"` // Per-project config file + Pattern string `mapstructure:"pattern"` // Glob pattern for discovery + Exclude []string `mapstructure:"exclude"` // Paths to exclude + DraftPR bool `mapstructure:"draft_pr"` // Open PRs as drafts } // TasksConfig defines task selection settings. @@ -564,3 +565,15 @@ func (c *Config) ExpandedProviderPath(provider string) string { return "" } } + +// ProjectByPath returns the ProjectConfig for the given path, or nil if not found. +// Both the input path and configured paths are expanded to handle tilde notation. +func (c *Config) ProjectByPath(path string) *ProjectConfig { + normalized := expandPath(path) + for i := range c.Projects { + if expandPath(c.Projects[i].Path) == normalized { + return &c.Projects[i] + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df34cdc..9a81a7a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -602,3 +602,54 @@ func TestValidate_CustomTaskDuplicateType(t *testing.T) { t.Errorf("expected ErrCustomTaskDuplicateType, got %v", err) } } + +func TestProjectByPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + + cfg := &Config{ + Projects: []ProjectConfig{ + {Path: "/home/user/project-a", DraftPR: true}, + {Path: "/home/user/project-b", DraftPR: false}, + {Path: "~/project-c", DraftPR: true}, + }, + } + + pc := cfg.ProjectByPath("/home/user/project-a") + if pc == nil { + t.Fatal("expected to find project-a") + } + if !pc.DraftPR { + t.Error("expected DraftPR to be true for project-a") + } + + pc = cfg.ProjectByPath("/home/user/project-b") + if pc == nil { + t.Fatal("expected to find project-b") + } + if pc.DraftPR { + t.Error("expected DraftPR to be false for project-b") + } + + pc = cfg.ProjectByPath("/nonexistent") + if pc != nil { + t.Error("expected nil for nonexistent path") + } + + // Tilde expansion: lookup with expanded path should match ~/project-c + pc = cfg.ProjectByPath(home + "/project-c") + if pc == nil { + t.Fatal("expected to find project-c via expanded path") + } + if !pc.DraftPR { + t.Error("expected DraftPR to be true for project-c") + } + + // Tilde expansion: lookup with tilde should also match + pc = cfg.ProjectByPath("~/project-c") + if pc == nil { + t.Fatal("expected to find project-c via tilde path") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..bf68d50 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -93,6 +93,7 @@ type RunMetadata struct { CostTier string RunStart time.Time Branch string // base branch for feature branches + DraftPR bool // open PRs as drafts } // Config holds orchestrator configuration. @@ -755,6 +756,11 @@ func (o *Orchestrator) buildImplementPrompt(task *tasks.Task, plan *PlanOutput, branchInstruction = fmt.Sprintf("\n Checkout `%s` before creating your feature branch.", o.runMeta.Branch) } + prInstruction := "open a PR" + if o.runMeta != nil && o.runMeta.DraftPR { + prInstruction = "open a **draft** PR (use `gh pr create --draft`). If draft creation fails (e.g. the repository plan does not support draft PRs), fall back to a regular PR and add the label `nightshift/draft` so it can be identified for review" + } + return fmt.Sprintf(`You are an implementation agent. Execute the plan for this task. ## Task @@ -770,7 +776,7 @@ Description: %s %s ## Instructions 0. Before creating your branch, record the current branch name. Create and work on a new branch. Never modify or commit directly to the primary branch.%s - When finished, open a PR. After the PR is submitted, switch back to the original branch. If you cannot open a PR, leave the branch and explain next steps. + When finished, %s. After the PR is submitted, switch back to the original branch. If you cannot open a PR, leave the branch and explain next steps. 1. If you create commits, include a concise message with these git trailers: Nightshift-Task: %s Nightshift-Ref: https://github.com/marcus/nightshift @@ -783,7 +789,7 @@ Description: %s "files_modified": ["file1.go", ...], "summary": "what was done" } -`, task.ID, task.Title, task.Description, plan.Description, plan.Steps, iterationNote, branchInstruction, task.Type) +`, task.ID, task.Title, task.Description, plan.Description, plan.Steps, iterationNote, branchInstruction, prInstruction, task.Type) } func (o *Orchestrator) buildReviewPrompt(task *tasks.Task, impl *ImplementOutput) string { diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 45bff5c..f83d44b 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -930,3 +930,71 @@ func TestRunTaskNoPRURL(t *testing.T) { t.Errorf("OutputRef = %q, want empty", result.OutputRef) } } + +func TestBuildImplementPrompt_DraftPR(t *testing.T) { + o := New() + o.SetRunMetadata(&RunMetadata{DraftPR: true}) + + task := &tasks.Task{ + ID: "draft-test", + Title: "Draft Test", + Description: "Test draft PR instruction", + } + plan := &PlanOutput{ + Steps: []string{"step1"}, + Description: "test plan", + } + + prompt := o.buildImplementPrompt(task, plan, 1) + if !strings.Contains(prompt, "draft") { + t.Errorf("implement prompt should contain draft instruction when DraftPR is true\nGot:\n%s", prompt) + } + if !strings.Contains(prompt, "gh pr create --draft") { + t.Errorf("implement prompt should contain 'gh pr create --draft'\nGot:\n%s", prompt) + } + if !strings.Contains(prompt, "nightshift/draft") { + t.Errorf("implement prompt should contain fallback label 'nightshift/draft'\nGot:\n%s", prompt) + } +} + +func TestBuildImplementPrompt_NoDraftPR(t *testing.T) { + o := New() + o.SetRunMetadata(&RunMetadata{DraftPR: false}) + + task := &tasks.Task{ + ID: "no-draft-test", + Title: "No Draft Test", + Description: "Test regular PR instruction", + } + plan := &PlanOutput{ + Steps: []string{"step1"}, + Description: "test plan", + } + + prompt := o.buildImplementPrompt(task, plan, 1) + if strings.Contains(prompt, "gh pr create --draft") { + t.Errorf("implement prompt should not contain draft PR instruction when DraftPR is false\nGot:\n%s", prompt) + } + if !strings.Contains(prompt, "open a PR") { + t.Errorf("implement prompt should contain 'open a PR'\nGot:\n%s", prompt) + } +} + +func TestBuildImplementPrompt_DraftPR_NoMetadata(t *testing.T) { + o := New() // no runMeta + + task := &tasks.Task{ + ID: "no-meta-test", + Title: "No Meta Test", + Description: "Test no metadata set", + } + plan := &PlanOutput{ + Steps: []string{"step1"}, + Description: "test plan", + } + + prompt := o.buildImplementPrompt(task, plan, 1) + if strings.Contains(prompt, "gh pr create --draft") { + t.Errorf("implement prompt should not contain draft PR instruction when no metadata\nGot:\n%s", prompt) + } +}