diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 519c380..129a8b3 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -711,8 +711,23 @@ func (o *Orchestrator) PlanPrompt(task *tasks.Task) string { return o.buildPlanPrompt(task) } -func taskSpecificPlanGuidance(task *tasks.Task) string { +func changelogComparisonBaseGuidance(baseBranch string) string { + if baseBranch != "" { + return fmt.Sprintf("- Nightshift provided `%s` as the comparison base; derive the commit range from `git merge-base %s HEAD` through `HEAD`, and exclude merge commits so the draft is deterministic.\n", baseBranch, baseBranch) + } + + return "- Determine the intended comparison base for this repo or PR flow; derive the commit range from `git merge-base HEAD` through `HEAD`, exclude merge commits so the draft stays deterministic, and state the assumption if the base branch is unclear.\n" +} + +func taskSpecificPlanGuidance(task *tasks.Task, baseBranch string) string { switch task.Type { + case tasks.TaskChangelogSynth: + return "\n\n## Task-Specific Guidance\n" + + "- Inspect the repo's current changelog and release signals before deciding the scope: CHANGELOG.md, release workflow files if present (for example .github/workflows/release.yml), and recent git tags/commits.\n" + + changelogComparisonBaseGuidance(baseBranch) + + "- If the release boundary or target changelog section is unclear, state the assumptions you made.\n" + + "- Prefer updating the existing changelog artifact and structure instead of inventing a new format, and preserve prior changelog history.\n" + + "- Plan for stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other. Omit empty sections when appropriate.\n" case tasks.TaskReleaseNotes: return "\n\n## Task-Specific Guidance\n" + "- Inspect the repo's existing release signals before deciding the scope: CHANGELOG.md, .github/workflows/release.yml, and recent git tags/commits.\n" + @@ -724,8 +739,16 @@ func taskSpecificPlanGuidance(task *tasks.Task) string { } } -func taskSpecificImplementGuidance(task *tasks.Task) string { +func taskSpecificImplementGuidance(task *tasks.Task, baseBranch string) string { switch task.Type { + case tasks.TaskChangelogSynth: + return "\n\n## Task-Specific Guidance\n" + + "- Inspect the repo's current changelog and release signals before drafting: CHANGELOG.md, release workflow files if present (for example .github/workflows/release.yml), and recent git tags/commits.\n" + + changelogComparisonBaseGuidance(baseBranch) + + "- Synthesize only the newest changelog section from supported commits, note assumptions if the release boundary or base branch is unclear, and do not invent entries.\n" + + "- Preserve prior changelog sections and structure; prefer updating the existing changelog artifact over creating a new format.\n" + + "- Organize the update into stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other. Omit empty sections when appropriate.\n" + + "- Emit Markdown-ready changelog content only.\n" case tasks.TaskReleaseNotes: return "\n\n## Task-Specific Guidance\n" + "- Inspect the repo's existing release signals before drafting: CHANGELOG.md, .github/workflows/release.yml, and recent git tags/commits.\n" + @@ -743,7 +766,12 @@ func (o *Orchestrator) buildPlanPrompt(task *tasks.Task) string { branchInstruction = fmt.Sprintf("\n Create your feature branch from `%s`.", o.runMeta.Branch) } - taskGuidance := taskSpecificPlanGuidance(task) + baseBranch := "" + if o.runMeta != nil { + baseBranch = o.runMeta.Branch + } + + taskGuidance := taskSpecificPlanGuidance(task, baseBranch) return fmt.Sprintf(`You are a planning agent. Create a detailed execution plan for this task. @@ -783,7 +811,12 @@ func (o *Orchestrator) buildImplementPrompt(task *tasks.Task, plan *PlanOutput, branchInstruction = fmt.Sprintf("\n Checkout `%s` before creating your feature branch.", o.runMeta.Branch) } - taskGuidance := taskSpecificImplementGuidance(task) + baseBranch := "" + if o.runMeta != nil { + baseBranch = o.runMeta.Branch + } + + taskGuidance := taskSpecificImplementGuidance(task, baseBranch) return fmt.Sprintf(`You are an implementation agent. Execute the plan for this task. diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 6c9ba4d..623b9fd 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -809,6 +809,79 @@ func TestBuildImplementPrompt_WithoutBranch(t *testing.T) { } } +func TestBuildPlanPrompt_ChangelogSynthUsesConfiguredBaseBranch(t *testing.T) { + o := New() + o.SetRunMetadata(&RunMetadata{Branch: "codex/release-notes-drafter"}) + + task := &tasks.Task{ + ID: "changelog-synth:/tmp/nightshift", + Title: "Changelog Synthesizer", + Description: "Draft a deterministic changelog update", + Type: tasks.TaskChangelogSynth, + } + + prompt := o.buildPlanPrompt(task) + + expected := []string{ + "CHANGELOG.md", + ".github/workflows/release.yml", + "Nightshift provided `codex/release-notes-drafter` as the comparison base", + "`git merge-base codex/release-notes-drafter HEAD`", + "exclude merge commits", + "preserve prior changelog history", + "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other", + "state the assumptions", + } + + for _, want := range expected { + if !strings.Contains(prompt, want) { + t.Errorf("plan prompt missing %q\nGot:\n%s", want, prompt) + } + } + + if strings.Contains(prompt, "git merge-base main HEAD") { + t.Errorf("plan prompt should not hardcode main\nGot:\n%s", prompt) + } +} + +func TestBuildImplementPrompt_ChangelogSynthWithoutConfiguredBaseUsesInference(t *testing.T) { + o := New() + + task := &tasks.Task{ + ID: "changelog-synth:/tmp/nightshift", + Title: "Changelog Synthesizer", + Description: "Draft a deterministic changelog update", + Type: tasks.TaskChangelogSynth, + } + plan := &PlanOutput{ + Steps: []string{"Inspect CHANGELOG.md", "Draft the newest changelog section"}, + Description: "Use the correct comparison base for the repo and preserve changelog structure.", + } + + prompt := o.buildImplementPrompt(task, plan, 1) + + expected := []string{ + "CHANGELOG.md", + ".github/workflows/release.yml", + "Determine the intended comparison base for this repo or PR flow", + "`git merge-base HEAD`", + "exclude merge commits", + "do not invent entries", + "Preserve prior changelog sections and structure", + "Emit Markdown-ready changelog content only", + } + + for _, want := range expected { + if !strings.Contains(prompt, want) { + t.Errorf("implement prompt missing %q\nGot:\n%s", want, prompt) + } + } + + if strings.Contains(prompt, "git merge-base main HEAD") { + t.Errorf("implement prompt should not hardcode main\nGot:\n%s", prompt) + } +} + func TestBuildPlanPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T) { o := New() @@ -871,7 +944,7 @@ func TestBuildImplementPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T } } -func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T) { +func TestBuildPrompts_GenericTasksDoNotReceiveTaskSpecificReleaseOrChangelogGuidance(t *testing.T) { o := New() task := &tasks.Task{ @@ -894,6 +967,10 @@ func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T) "release boundary", "current release-notes artifact and format", "Features, Fixes, Breaking Changes, and Other", + "git merge-base HEAD", + "comparison base", + "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other", + "Markdown-ready changelog content only", } for _, notWant := range unexpected { diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 0959c08..0e7ef4a 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -338,10 +338,15 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain DefaultInterval: 24 * time.Hour, }, TaskChangelogSynth: { - Type: TaskChangelogSynth, - Category: CategoryPR, - Name: "Changelog Synthesizer", - Description: "Generate changelog from commits", + Type: TaskChangelogSynth, + Category: CategoryPR, + Name: "Changelog Synthesizer", + Description: `Inspect the current branch against its comparison base and synthesize the next changelog update for this repository. +When Nightshift provides a base branch, use that branch as the comparison target; otherwise determine the repo's intended release/base branch and state the assumption. +Derive the commit range from git merge-base HEAD through HEAD, excluding merge commits so the output stays deterministic. +Review the existing changelog artifact and preserve prior history and structure, updating only the newest relevant section instead of rewriting older entries. +Group entries into stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other, omitting empty sections when appropriate. +Base every bullet on actual commits, do not invent changes, and emit Markdown-ready changelog content only.`, CostTier: CostLow, RiskLevel: RiskLow, DefaultInterval: 168 * time.Hour, diff --git a/internal/tasks/tasks_test.go b/internal/tasks/tasks_test.go index 03cdf18..da91218 100644 --- a/internal/tasks/tasks_test.go +++ b/internal/tasks/tasks_test.go @@ -1,6 +1,7 @@ package tasks import ( + "strings" "testing" "time" ) @@ -104,6 +105,30 @@ func TestGetDefinition(t *testing.T) { } } +func TestTaskChangelogSynthDefinitionIncludesDeterministicRepoAwareGuidance(t *testing.T) { + def, err := GetDefinition(TaskChangelogSynth) + if err != nil { + t.Fatalf("GetDefinition(TaskChangelogSynth) returned error: %v", err) + } + + expected := []string{ + "comparison base", + "Nightshift provides a base branch", + "git merge-base HEAD", + "excluding merge commits", + "preserve prior history and structure", + "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other", + "do not invent changes", + "Markdown-ready changelog content only", + } + + for _, want := range expected { + if !strings.Contains(def.Description, want) { + t.Errorf("TaskChangelogSynth description missing %q\nGot:\n%s", want, def.Description) + } + } +} + func TestGetCostEstimate(t *testing.T) { // Low cost task min, max, err := GetCostEstimate(TaskLintFix) diff --git a/website/docs/task-reference.md b/website/docs/task-reference.md index 754a067..3fc97e7 100644 --- a/website/docs/task-reference.md +++ b/website/docs/task-reference.md @@ -24,7 +24,7 @@ Fully formed, review-ready artifacts. These tasks create branches and open pull | `build-optimize` | Build Time Optimization | Optimize build configuration for faster builds | High | Medium | 7d | | `docs-backfill` | Documentation Backfiller | Generate missing documentation | Low | Low | 7d | | `commit-normalize` | Commit Message Normalizer | Standardize commit message format | Low | Low | 24h | -| `changelog-synth` | Changelog Synthesizer | Generate changelog from commits | Low | Low | 7d | +| `changelog-synth` | Changelog Synthesizer | Draft a deterministic changelog update from current-branch commits since `git merge-base HEAD`, using Nightshift's configured base branch when available and preserving existing `CHANGELOG.md` structure | Low | Low | 7d | | `release-notes` | Release Note Drafter | Draft release-ready notes for the next version | Low | Low | 7d | | `adr-draft` | ADR Drafter | Draft Architecture Decision Records | Medium | Low | 7d | | `td-review` | TD Review Session | Review open td reviews, fix obvious bugs, create tasks for bigger issues | High | Medium | 72h |