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
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
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`). 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
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
68 changes: 68 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}