diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..411482f 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -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 @@ -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)) @@ -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 != "" { @@ -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 @@ -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 { @@ -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 @@ -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 { diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 45bff5c..e5baa35 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -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 @@ -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{ @@ -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) @@ -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