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
124 changes: 115 additions & 9 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const (
DefaultAgentTimeout = 30 * time.Minute
)

const (
commitDefaultSubject = "chore: update task"
nightshiftRef = "https://github.com/marcus/nightshift"
nightshiftTaskKey = "Nightshift-Task"
nightshiftRefKey = "Nightshift-Ref"
)

// TaskStatus represents the outcome of task execution.
type TaskStatus string

Expand Down Expand Up @@ -664,7 +671,7 @@ func (o *Orchestrator) review(ctx context.Context, task *tasks.Task, impl *Imple
func (o *Orchestrator) commit(_ context.Context, task *tasks.Task, impl *ImplementOutput, _ string) error {
// For now, commit is a no-op. In full implementation:
// - Create git commit with changes
// - Include a commit message with https://github.com/marcus/nightshift
// - Use normalizeCommitMessage for the commit message
// - Update task state
// - Send notifications
o.logger.Infof("commit: task=%s files=%d", task.ID, len(impl.FilesModified))
Expand Down Expand Up @@ -711,6 +718,109 @@ func (o *Orchestrator) PlanPrompt(task *tasks.Task) string {
return o.buildPlanPrompt(task)
}

func normalizedCommitGuidance(taskType tasks.TaskType) string {
message := normalizeCommitMessage("type(scope): subject", "[optional body after a blank line]", taskType)
return "use this normalized commit-message format:\n" + indentCommitMessage(message, " ")
}

func normalizeCommitMessage(subject, body string, taskType tasks.TaskType) string {
subject, body = splitCommitSubjectBody(subject, body)
cleanSubject := normalizeCommitSubject(subject)
cleanBody := normalizeCommitBody(body)

var b strings.Builder
b.WriteString(cleanSubject)
b.WriteString("\n\n")
if cleanBody != "" {
b.WriteString(cleanBody)
b.WriteString("\n\n")
}
fmt.Fprintf(&b, "%s: %s\n", nightshiftTaskKey, taskType)
fmt.Fprintf(&b, "%s: %s", nightshiftRefKey, nightshiftRef)
return b.String()
}

func splitCommitSubjectBody(subject, body string) (string, string) {
subject = strings.TrimSpace(subject)
body = strings.TrimSpace(body)
if body != "" || !strings.Contains(subject, "\n") {
return subject, body
}

parts := strings.SplitN(subject, "\n", 2)
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}

var conventionalSubjectPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*(\([a-z0-9._/-]+\))?!?: .+`)
var conventionalLikeSubjectPattern = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*(\([A-Za-z0-9._/-]+\))?!?):\s*(.+)$`)

func normalizeCommitSubject(subject string) string {
for _, line := range strings.Split(subject, "\n") {
if isNightshiftTrailer(line) {
continue
}
clean := collapseWhitespace(line)
if clean == "" {
continue
}
if conventionalSubjectPattern.MatchString(clean) {
return clean
}
if matches := conventionalLikeSubjectPattern.FindStringSubmatch(clean); matches != nil {
return strings.ToLower(matches[1]) + ": " + collapseWhitespace(matches[3])
}
return "chore: " + clean
}
return commitDefaultSubject
}

func normalizeCommitBody(body string) string {
lines := strings.Split(body, "\n")
normalized := make([]string, 0, len(lines))
blankPending := false

for _, line := range lines {
if isNightshiftTrailer(line) {
continue
}
clean := collapseWhitespace(line)
if clean == "" {
if len(normalized) > 0 {
blankPending = true
}
continue
}
if blankPending {
normalized = append(normalized, "")
blankPending = false
}
normalized = append(normalized, clean)
}

return strings.TrimSpace(strings.Join(normalized, "\n"))
}

func collapseWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}

func isNightshiftTrailer(line string) bool {
line = strings.TrimSpace(line)
return strings.HasPrefix(line, nightshiftTaskKey+":") || strings.HasPrefix(line, nightshiftRefKey+":")
}

func indentCommitMessage(message, prefix string) string {
lines := strings.Split(message, "\n")
for i, line := range lines {
if line == "" {
lines[i] = prefix
continue
}
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
}

func (o *Orchestrator) buildPlanPrompt(task *tasks.Task) string {
branchInstruction := ""
if o.runMeta != nil && o.runMeta.Branch != "" {
Expand All @@ -728,9 +838,7 @@ Description: %s
0. You are running autonomously. If the task is broad or ambiguous, choose a concrete, minimal scope that delivers value and state any assumptions in the description.
1. Work on a new branch and plan to submit a PR. Never work directly on the primary branch.%s
2. Before creating your branch, record the current branch name and plan to switch back after the PR is opened.
3. If you create commits, include a concise message with these git trailers:
Nightshift-Task: %s
Nightshift-Ref: https://github.com/marcus/nightshift
3. If you create commits, %s
4. Analyze the task requirements
5. Identify files that need to be modified
6. Create step-by-step implementation plan
Expand All @@ -741,7 +849,7 @@ Description: %s
"files": ["file1.go", "file2.go", ...],
"description": "overall approach"
}
`, task.ID, task.Title, task.Description, branchInstruction, task.Type)
`, task.ID, task.Title, task.Description, branchInstruction, normalizedCommitGuidance(task.Type))
}

func (o *Orchestrator) buildImplementPrompt(task *tasks.Task, plan *PlanOutput, iteration int) string {
Expand Down Expand Up @@ -771,9 +879,7 @@ Description: %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.
1. If you create commits, include a concise message with these git trailers:
Nightshift-Task: %s
Nightshift-Ref: https://github.com/marcus/nightshift
1. If you create commits, %s
2. Implement the plan step by step
3. Make all necessary code changes
4. Ensure tests pass
Expand All @@ -783,7 +889,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, normalizedCommitGuidance(task.Type))
}

func (o *Orchestrator) buildReviewPrompt(task *tasks.Task, impl *ImplementOutput) string {
Expand Down
155 changes: 155 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ func TestBuildPrompts(t *testing.T) {
ID: "prompt-test",
Title: "Build Prompts",
Description: "Test prompt generation",
Type: tasks.TaskCommitNormalize,
}

// Test plan prompt
Expand All @@ -440,6 +441,7 @@ func TestBuildPrompts(t *testing.T) {
if !containsIgnoreCase(planPrompt, "prompt-test") {
t.Error("plan prompt should contain task ID")
}
assertCommitGuidance(t, planPrompt, task.Type)

// Test implement prompt
plan := &PlanOutput{
Expand All @@ -450,6 +452,7 @@ func TestBuildPrompts(t *testing.T) {
if !containsIgnoreCase(implPrompt, "implementation") {
t.Error("implement prompt should mention implementation")
}
assertCommitGuidance(t, implPrompt, task.Type)

// Test implement prompt iteration 2
implPrompt2 := o.buildImplementPrompt(task, plan, 2)
Expand All @@ -468,6 +471,158 @@ func TestBuildPrompts(t *testing.T) {
}
}

func TestNormalizeCommitMessage(t *testing.T) {
tests := []struct {
name string
subject string
body string
want string
}{
{
name: "whitespace cleanup",
subject: " fix: normalize commit messages \nignored second subject line",
body: " Body line one.\n\n\n Body line two. ",
want: strings.Join([]string{
"fix: normalize commit messages",
"",
"Body line one.",
"",
"Body line two.",
"",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}, "\n"),
},
{
name: "existing trailer replacement",
subject: "chore: normalize prompts",
body: strings.Join([]string{
"Keep this body.",
"",
"Nightshift-Task: old-task",
"Nightshift-Task: duplicate-task",
"Nightshift-Ref: https://example.com/old",
}, "\n"),
want: strings.Join([]string{
"chore: normalize prompts",
"",
"Keep this body.",
"",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}, "\n"),
},
{
name: "missing trailers",
subject: "docs: update prompt guidance",
want: strings.Join([]string{
"docs: update prompt guidance",
"",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}, "\n"),
},
{
name: "empty subject fallback",
subject: "",
body: "Nightshift-Task: stale",
want: strings.Join([]string{
"chore: update task",
"",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}, "\n"),
},
{
name: "preserve concise body",
subject: "Commit message normalizer",
body: strings.Join([]string{
"Preserve this summary.",
"",
"Keep the second paragraph concise.",
}, "\n"),
want: strings.Join([]string{
"chore: Commit message normalizer",
"",
"Preserve this summary.",
"",
"Keep the second paragraph concise.",
"",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}, "\n"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeCommitMessage(tt.subject, tt.body, tasks.TaskCommitNormalize)
if got != tt.want {
t.Errorf("normalizeCommitMessage() =\n%s\nwant:\n%s", got, tt.want)
}
if strings.Count(got, "Nightshift-Task:") != 1 {
t.Errorf("Nightshift-Task trailer count = %d, want 1", strings.Count(got, "Nightshift-Task:"))
}
if strings.Count(got, "Nightshift-Ref:") != 1 {
t.Errorf("Nightshift-Ref trailer count = %d, want 1", strings.Count(got, "Nightshift-Ref:"))
}
})
}
}

func TestPlanAndImplementPromptsUseNormalizedCommitGuidance(t *testing.T) {
o := New()
task := &tasks.Task{
ID: "commit-normalize:/repo",
Title: "Commit Message Normalizer",
Description: "Standardize commit message format",
Type: tasks.TaskCommitNormalize,
}
plan := &PlanOutput{
Steps: []string{"update prompts", "add tests"},
Description: "Normalize future generated commit messages",
}

for name, prompt := range map[string]string{
"plan": o.buildPlanPrompt(task),
"implement": o.buildImplementPrompt(task, plan, 1),
} {
t.Run(name, func(t *testing.T) {
assertCommitGuidance(t, prompt, task.Type)
for _, absent := range []string{
"include a concise message with these git trailers",
"these git trailers",
} {
if strings.Contains(prompt, absent) {
t.Errorf("prompt contains old commit instruction %q\ngot:\n%s", absent, prompt)
}
}
})
}
}

func assertCommitGuidance(t *testing.T, prompt string, taskType tasks.TaskType) {
t.Helper()

for _, want := range []string{
"use this normalized commit-message format:",
"type(scope): subject",
"[optional body after a blank line]",
"Nightshift-Task: " + string(taskType),
"Nightshift-Ref: https://github.com/marcus/nightshift",
} {
if !strings.Contains(prompt, want) {
t.Errorf("prompt missing %q\ngot:\n%s", want, prompt)
}
}
if strings.Count(prompt, "Nightshift-Task:") != 1 {
t.Errorf("Nightshift-Task trailer count = %d, want 1", strings.Count(prompt, "Nightshift-Task:"))
}
if strings.Count(prompt, "Nightshift-Ref:") != 1 {
t.Errorf("Nightshift-Ref trailer count = %d, want 1", strings.Count(prompt, "Nightshift-Ref:"))
}
}

func TestExtractPRURL(t *testing.T) {
tests := []struct {
name string
Expand Down