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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,15 @@ Analytics are stored locally and help identify workflow patterns. Disable with `
| Query issues | `td query "expression"` |
| Search text | `td search "keyword"` |

### System Commands

| Action | Command |
| ------------------- | -------------------------------------------------------------- |
| Initialize project | `td init` |
| Show version | `td version` |
| Draft release notes | `td release-notes --version vX.Y.Z [--from ref] [--to ref]` |
| JSON release draft | `td release-notes --json --include-internal --version vX.Y.Z` |

## Live Monitor

> Full documentation: [Live Monitor](https://marcus.github.io/td/docs/monitor)
Expand Down
73 changes: 73 additions & 0 deletions cmd/release_notes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/marcus/td/internal/releasenotes"
"github.com/spf13/cobra"
)

func newReleaseNotesCmd() *cobra.Command {
var opts releasenotes.Options
var jsonOutput bool

cmd := &cobra.Command{
Use: "release-notes",
Short: "Draft release notes from git commits",
GroupID: "system",
RunE: func(cmd *cobra.Command, args []string) error {
repoDir, err := releaseNotesStartDir()
if err != nil {
return err
}
opts.RepoDir = repoDir

draft, err := releasenotes.DraftFromGit(context.Background(), opts)
if err != nil {
return err
}

out := cmd.OutOrStdout()
if jsonOutput {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(draft)
}

_, err = fmt.Fprint(out, releasenotes.RenderMarkdown(draft))
return err
},
}

cmd.Flags().StringVar(&opts.From, "from", "", "Base git ref (default: latest v* tag before --to)")
cmd.Flags().StringVar(&opts.To, "to", "HEAD", "Target git ref")
cmd.Flags().StringVar(&opts.Version, "version", "", "Release version heading, for example v0.5.0")
cmd.Flags().StringVar(&opts.Date, "date", "", "Release date in YYYY-MM-DD format")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit structured JSON")
cmd.Flags().BoolVar(&opts.IncludeInternal, "include-internal", false, "Include internal maintenance commits")

return cmd
}

func releaseNotesStartDir() (string, error) {
if workDirFlag != "" {
return normalizeWorkDir(workDirFlag), nil
}
if envDir := os.Getenv("TD_WORK_DIR"); envDir != "" {
return normalizeWorkDir(envDir), nil
}
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
}
return dir, nil
}

var releaseNotesCmd = newReleaseNotesCmd()

func init() {
rootCmd.AddCommand(releaseNotesCmd)
}
251 changes: 251 additions & 0 deletions cmd/release_notes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package cmd

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/marcus/td/internal/releasenotes"
)

func TestReleaseNotesDefaultRangeUsesLatestVersionTag(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")
runGit(t, repo, "tag", "v0.1.0")
featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat(cli): add release notes")

out, err := runReleaseNotesCommand(t, repo, "--version", "v0.2.0")
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

for _, want := range []string{
"## v0.2.0",
"_Range: `v0.1.0..HEAD` (1 commits)_",
"### Features",
"- add release notes (" + featureSHA + ")",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "initial release") {
t.Fatalf("default range should exclude tagged commit:\n%s", out)
}
}

func TestReleaseNotesExplicitFromTo(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")
runGit(t, repo, "tag", "v0.1.0")
featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add board export")
fixSHA := commitFile(t, repo, "fix.txt", "fix\n", "fix: handle missing board")
commitFile(t, repo, "docs.txt", "docs\n", "docs: explain board export")

out, err := runReleaseNotesCommand(t, repo, "--from", "v0.1.0", "--to", fixSHA)
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

for _, want := range []string{
"_Range: `v0.1.0.." + fixSHA + "` (2 commits)_",
"- add board export (" + featureSHA + ")",
"- handle missing board (" + fixSHA + ")",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "explain board export") {
t.Fatalf("explicit --to should exclude later commit:\n%s", out)
}
}

func TestReleaseNotesDefaultRangeUsesHistoricalToRef(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")
runGit(t, repo, "tag", "v0.1.0")
historicalSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add historical feature")
commitFile(t, repo, "fix.txt", "fix\n", "fix: handle later bug")
runGit(t, repo, "tag", "v0.2.0")
commitFile(t, repo, "future.txt", "future\n", "feat: add future feature")
runGit(t, repo, "tag", "v0.3.0")

out, err := runReleaseNotesCommand(t, repo, "--to", historicalSHA, "--version", "v0.2.0")
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

for _, want := range []string{
"_Range: `v0.1.0.." + historicalSHA + "` (1 commits)_",
"- add historical feature (" + historicalSHA + ")",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
for _, unwanted := range []string{"handle later bug", "add future feature"} {
if strings.Contains(out, unwanted) {
t.Fatalf("historical --to should exclude %q:\n%s", unwanted, out)
}
}
}

func TestReleaseNotesDefaultRangeForTaggedToUsesPreviousVersionTag(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")
runGit(t, repo, "tag", "v0.1.0")
featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add tagged release feature")
fixSHA := commitFile(t, repo, "fix.txt", "fix\n", "fix: handle tagged release bug")
runGit(t, repo, "tag", "v0.2.0")
commitFile(t, repo, "future.txt", "future\n", "feat: add future feature")
runGit(t, repo, "tag", "v0.3.0")

out, err := runReleaseNotesCommand(t, repo, "--to", "v0.2.0", "--version", "v0.2.0")
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

for _, want := range []string{
"_Range: `v0.1.0..v0.2.0` (2 commits)_",
"- add tagged release feature (" + featureSHA + ")",
"- handle tagged release bug (" + fixSHA + ")",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "add future feature") {
t.Fatalf("tagged --to should exclude future commits:\n%s", out)
}
}

func TestReleaseNotesJSONOutput(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")
runGit(t, repo, "tag", "v0.1.0")
commitFile(t, repo, "fix.txt", "fix\n", "fix(api): handle empty release range")

out, err := runReleaseNotesCommand(t, repo, "--from", "v0.1.0", "--json", "--version", "v0.2.0")
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

var draft releasenotes.Draft
if err := json.Unmarshal([]byte(out), &draft); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out)
}
if draft.Version != "v0.2.0" || draft.From != "v0.1.0" || draft.To != "HEAD" {
t.Fatalf("unexpected draft metadata: %+v", draft)
}
if draft.CommitCount != 1 {
t.Fatalf("commit count = %d, want 1", draft.CommitCount)
}
items := releaseNotesSectionItems(draft, releasenotes.SectionFixes)
if len(items) != 1 {
t.Fatalf("bug fix items = %d, want 1", len(items))
}
if items[0].Subject != "handle empty release range" || items[0].Scope != "api" {
t.Fatalf("unexpected bug fix item: %+v", items[0])
}
}

func TestReleaseNotesEmptyRange(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")

out, err := runReleaseNotesCommand(t, repo, "--from", "HEAD", "--to", "HEAD")
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "_Range: `HEAD..HEAD` (0 commits)_") {
t.Fatalf("missing empty range metadata:\n%s", out)
}
if !strings.Contains(out, "_No release note entries found._") {
t.Fatalf("missing empty draft message:\n%s", out)
}
}

func TestReleaseNotesNoTagsRequiresFrom(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")

_, err := runReleaseNotesCommand(t, repo)
if err == nil {
t.Fatal("expected error without v* tags or --from")
}
if !strings.Contains(err.Error(), "no v* tags found") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestReleaseNotesInvalidDate(t *testing.T) {
repo := newReleaseNotesRepo(t)
commitFile(t, repo, "README.md", "initial\n", "feat: initial release")

_, err := runReleaseNotesCommand(t, repo, "--from", "HEAD", "--to", "HEAD", "--date", "05/08/2026")
if err == nil {
t.Fatal("expected invalid date error")
}
if !strings.Contains(err.Error(), "invalid --date") {
t.Fatalf("unexpected error: %v", err)
}
}

func runReleaseNotesCommand(t *testing.T, repo string, args ...string) (string, error) {
t.Helper()
saveAndRestoreGlobals(t)
workDirFlag = ""
t.Setenv("TD_WORK_DIR", "")
t.Chdir(repo)

cmd := newReleaseNotesCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(args)
err := cmd.Execute()
return out.String(), err
}

func newReleaseNotesRepo(t *testing.T) string {
t.Helper()
repo := initGitRepo(t)
runGit(t, repo, "config", "user.email", "td@example.com")
runGit(t, repo, "config", "user.name", "td tests")
return repo
}

func commitFile(t *testing.T, repo, name, content, subject string) string {
t.Helper()
path := filepath.Join(repo, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", name)
runGit(t, repo, "commit", "-m", subject)
return gitOutput(t, repo, "rev-parse", "--short", "HEAD")
}

func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out))
}

func releaseNotesSectionItems(draft releasenotes.Draft, id string) []releasenotes.Item {
for _, section := range draft.Sections {
if section.ID == id {
return section.Items
}
}
return nil
}
15 changes: 13 additions & 2 deletions docs/guides/releasing-new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,16 @@ Check current version:
git tag -l | sort -V | tail -1
```

### 2. Update CHANGELOG.md
### 2. Draft and Update CHANGELOG.md

Draft release notes from commits since the latest `v*` tag before the target release:

```bash
td release-notes --version vX.Y.Z
# Optional: add --to vX.Y.Z, --date YYYY-MM-DD, or --json for structured review
```

Review and curate the generated notes, then paste the result into the top of `CHANGELOG.md` or into GitHub release notes. The drafter is local-only: it reads git commits and does not edit the changelog, create tags, publish releases, or call external services.

Add entry at the top of `CHANGELOG.md`:

Expand Down Expand Up @@ -135,7 +144,8 @@ git status
go test ./...

# Update changelog
# (Edit CHANGELOG.md, add entry at top)
# Draft notes, then edit CHANGELOG.md and add the curated entry at top
td release-notes --version vX.Y.Z
git add CHANGELOG.md
git commit -m "docs: Update changelog for vX.Y.Z"

Expand All @@ -154,6 +164,7 @@ brew upgrade td && td version

- [ ] Tests pass (`go test ./...`)
- [ ] Working tree clean
- [ ] Release notes drafted with `td release-notes --version vX.Y.Z`
- [ ] CHANGELOG.md updated with new version entry
- [ ] Changelog committed to git
- [ ] Version number follows semver
Expand Down
Loading
Loading