From f38678c829bf4a4efee1da8f2a0d3898c105d99f Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Fri, 17 Apr 2026 04:42:54 -0700 Subject: [PATCH] fix: scope changelog synth to documented branch Nightshift-Task: changelog-synth Nightshift-Ref: https://github.com/marcus/nightshift --- internal/orchestrator/orchestrator.go | 52 ++++++++++- internal/orchestrator/orchestrator_test.go | 100 ++++++++++++++++++++- internal/tasks/tasks.go | 14 ++- internal/tasks/tasks_test.go | 37 ++++++++ website/docs/task-reference.md | 2 +- 5 files changed, 195 insertions(+), 10 deletions(-) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 519c380..67aef99 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -711,8 +711,34 @@ func (o *Orchestrator) PlanPrompt(task *tasks.Task) string { return o.buildPlanPrompt(task) } -func taskSpecificPlanGuidance(task *tasks.Task) string { +func changelogDocumentedBranchGuidance(documentedBranch string) string { + if documentedBranch != "" { + return fmt.Sprintf( + "- Nightshift provided `%s` as the branch being documented. Use `%s` as the changelog subject even after you create your working branch; do not compare the temporary implementation branch against itself.\n"+ + "- Determine `%s`'s comparison base from PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow. If the base is unclear, state the assumption you made.\n"+ + "- Derive the commit range from `git merge-base %s` through `%s`, and exclude merge commits so the draft stays deterministic.\n", + documentedBranch, + documentedBranch, + documentedBranch, + documentedBranch, + documentedBranch, + ) + } + + return "- Record the branch you started on before creating the Nightshift working branch and use that recorded branch as the changelog subject; do not compare the temporary implementation branch against itself.\n" + + "- Determine that branch's comparison base from PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow. If the base is unclear, state the assumption you made.\n" + + "- Derive the commit range from `git merge-base ` through ``, and exclude merge commits so the draft stays deterministic.\n" +} + +func taskSpecificPlanGuidance(task *tasks.Task, documentedBranch 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" + + changelogDocumentedBranchGuidance(documentedBranch) + + "- 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 +750,16 @@ func taskSpecificPlanGuidance(task *tasks.Task) string { } } -func taskSpecificImplementGuidance(task *tasks.Task) string { +func taskSpecificImplementGuidance(task *tasks.Task, documentedBranch 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" + + changelogDocumentedBranchGuidance(documentedBranch) + + "- Synthesize only the newest changelog section from commits in that range, note assumptions if the release boundary 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 +777,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) + documentedBranch := "" + if o.runMeta != nil { + documentedBranch = o.runMeta.Branch + } + + taskGuidance := taskSpecificPlanGuidance(task, documentedBranch) return fmt.Sprintf(`You are a planning agent. Create a detailed execution plan for this task. @@ -783,7 +822,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) + documentedBranch := "" + if o.runMeta != nil { + documentedBranch = o.runMeta.Branch + } + + taskGuidance := taskSpecificImplementGuidance(task, documentedBranch) 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..4fb057e 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -809,6 +809,99 @@ func TestBuildImplementPrompt_WithoutBranch(t *testing.T) { } } +func TestBuildPlanPrompt_ChangelogSynthUsesProvidedBranchAsDocumentedBranch(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 branch being documented", + "Use `codex/release-notes-drafter` as the changelog subject even after you create your working branch", + "Determine `codex/release-notes-drafter`'s comparison base", + "`git merge-base codex/release-notes-drafter`", + "exclude merge commits", + "preserve prior changelog history", + "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other", + "state the assumptions you made", + } + + for _, want := range expected { + if !strings.Contains(prompt, want) { + t.Errorf("plan prompt missing %q\nGot:\n%s", want, prompt) + } + } + + unexpected := []string{ + "as the comparison base", + "`git merge-base codex/release-notes-drafter HEAD`", + "git merge-base main HEAD", + } + + for _, notWant := range unexpected { + if strings.Contains(prompt, notWant) { + t.Errorf("plan prompt should not contain %q\nGot:\n%s", notWant, prompt) + } + } +} + +func TestBuildImplementPrompt_ChangelogSynthWithoutProvidedBranchUsesRecordedBranch(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 documented branch and preserve changelog structure.", + } + + prompt := o.buildImplementPrompt(task, plan, 1) + + expected := []string{ + "CHANGELOG.md", + ".github/workflows/release.yml", + "Record the branch you started on before creating the Nightshift working branch", + "do not compare the temporary implementation branch against itself", + "PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow", + "`git merge-base `", + "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) + } + } + + unexpected := []string{ + "as the comparison base", + "`git merge-base HEAD`", + "git merge-base main HEAD", + } + + for _, notWant := range unexpected { + if strings.Contains(prompt, notWant) { + t.Errorf("implement prompt should not contain %q\nGot:\n%s", notWant, prompt) + } + } +} + func TestBuildPlanPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T) { o := New() @@ -871,7 +964,7 @@ func TestBuildImplementPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T } } -func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T) { +func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesOrChangelogGuidance(t *testing.T) { o := New() task := &tasks.Task{ @@ -894,6 +987,11 @@ func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T) "release boundary", "current release-notes artifact and format", "Features, Fixes, Breaking Changes, and Other", + "branch being documented", + "temporary implementation branch against itself", + "git merge-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..c732939 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -338,10 +338,16 @@ 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 repo's current changelog and synthesize the next changelog update for the branch being documented. +Use the branch Nightshift checked out before your working branch as the branch to document, or record the original branch yourself if none was provided. +Determine that branch's comparison base from PR metadata, upstream tracking info, or the repo's release/default-branch workflow, and state the assumption if the base is unclear. +Derive the commit range from git merge-base through , excluding merge commits so the output stays deterministic. +Preserve existing changelog 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..aff5ef5 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,42 @@ func TestGetDefinition(t *testing.T) { } } +func TestTaskChangelogSynthDefinitionIncludesDeterministicBranchAwareGuidance(t *testing.T) { + def, err := GetDefinition(TaskChangelogSynth) + if err != nil { + t.Fatalf("GetDefinition(TaskChangelogSynth) returned error: %v", err) + } + + expected := []string{ + "branch being documented", + "branch Nightshift checked out before your working branch", + "PR metadata, upstream tracking info, or the repo's release/default-branch workflow", + "git merge-base ", + "excluding merge commits", + "Preserve existing changelog 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) + } + } + + unexpected := []string{ + "Nightshift provides a base branch", + "git merge-base HEAD", + } + + for _, notWant := range unexpected { + if strings.Contains(def.Description, notWant) { + t.Errorf("TaskChangelogSynth description should not contain %q\nGot:\n%s", notWant, 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..de1db23 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 for the branch being documented, deriving real commits from `git merge-base ` 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 |