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
52 changes: 48 additions & 4 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <base> <recorded-branch>` through `<recorded-branch>`, 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" +
Expand All @@ -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" +
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
100 changes: 99 additions & 1 deletion internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <base> <recorded-branch>`",
"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()

Expand Down Expand Up @@ -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{
Expand All @@ -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 <base> <recorded-branch>",
"Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other",
"Markdown-ready changelog content only",
}

for _, notWant := range unexpected {
Expand Down
14 changes: 10 additions & 4 deletions internal/tasks/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base> <branch-being-documented> through <branch-being-documented>, 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,
Expand Down
37 changes: 37 additions & 0 deletions internal/tasks/tasks_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tasks

import (
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -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 <base> <branch-being-documented>",
"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 <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)
Expand Down
2 changes: 1 addition & 1 deletion website/docs/task-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base> <branch>` 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 |
Expand Down