Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions cmd/nightshift/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,13 +729,18 @@ 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),
TaskScore: scoredTask.Score,
CostTier: scoredTask.Definition.CostTier.String(),
RunStart: projectStart,
Branch: p.branch,
DraftPR: draftPR,
})

// Execute via orchestrator
Expand Down
9 changes: 9 additions & 0 deletions cmd/nightshift/commands/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of wonder whether draft should be the default and we invert this 🤔

_ = taskRunCmd.MarkFlagRequired("provider")

taskCmd.AddCommand(taskListCmd)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
51 changes: 51 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
10 changes: 8 additions & 2 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` or equivalent)"
}

return fmt.Sprintf(`You are an implementation agent. Execute the plan for this task.

## Task
Expand All @@ -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
Expand All @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -930,3 +930,68 @@ 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)
}
}

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)
}
}