diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c4f1ffc..ca1620ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,9 +78,14 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v4 + - name: Extract release notes from changelog + run: python3 scripts/changelog.py extract --version "${GITHUB_REF_NAME#v}" --output RELEASE_NOTES.md + - name: Create release uses: softprops/action-gh-release@v2 with: @@ -89,4 +94,4 @@ jobs: herdr-linux-aarch64/herdr-linux-aarch64 herdr-macos-x86_64/herdr-macos-x86_64 herdr-macos-aarch64/herdr-macos-aarch64 - generate_release_notes: true + body_path: RELEASE_NOTES.md diff --git a/.gitignore b/.gitignore index 84b8c03e..83578415 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target +__pycache__/ +*.pyc *.swp *.swo *~ diff --git a/.pi/prompts/draft-changelog.md b/.pi/prompts/draft-changelog.md new file mode 100644 index 00000000..e32df7a1 --- /dev/null +++ b/.pi/prompts/draft-changelog.md @@ -0,0 +1,73 @@ +--- +description: Draft changelog entry from commits and PRs since last tag +--- +Draft a changelog entry for this repo. + +Optional starting ref override: `$1` +Extra user intent/context: `${@:2}` + +Process: + +1. Determine the base ref. + - If `$1` is non-empty and looks like a ref/tag, use it. + - Otherwise use the latest release tag, preferring the repo's semver tag style (for example `v0.1.2`): + ```bash + git describe --tags --abbrev=0 + ``` + +2. Inspect the range from base ref to `HEAD`. + - Use first-parent history for release context: + ```bash + git log --first-parent --reverse --format='%H%x09%s' ..HEAD + ``` + - Also inspect full commits when needed: + ```bash + git log --reverse --format='%H%x09%s' ..HEAD + ``` + +3. Detect merged PRs if any. + - Look for first-parent subjects that indicate PR merges, including squash merges like `title (#123)`. + - If GitHub CLI is available and the PR number is known, use it to fetch PR title/body for context. + - Treat a merged PR as the primary release unit. + - Do **not** also list the individual commits that belong to that PR. + +4. Handle direct commits separately. + - Any commit in the range not represented by a merged PR should be considered on its own. + +5. Infer what matters. + - For each PR or direct commit, inspect changed files and diff stats. + - Read the most relevant files in full when needed to understand user-facing impact. + - Ignore pure housekeeping unless it has release value: + - version bumps + - release/tag commits + - changelog-only commits + - formatting-only changes + - comment-only/doc-only changes unless they materially affect users + +6. Draft the changelog entry. + - Group items under these sections when applicable: + - `### Added` + - `### Changed` + - `### Fixed` + - `### Removed` + - `### Breaking Changes` + - Write for end users, not for commit archaeology. + - Merge related low-level commits into one human-readable bullet when appropriate. + - Keep bullets concrete and outcome-focused. + - Prefer one bullet per meaningful shipped change. + - If there are both PRs and direct commits, include both, but exclude direct commits already covered by PRs. + +7. Respect repo reality. + - If `CHANGELOG.md` exists, read it before proposing edits and follow its existing style. + - If no changelog file exists, say so explicitly and produce a draft entry only. + - Do not edit files yet unless the user explicitly asks you to apply the draft. + +Output format: + +- `Base ref:` +- `PRs included:` +- `Direct commits included:` +- `Excluded as housekeeping:` +- `Proposed changelog entry:` + +If the range has no meaningful user-facing changes, say that plainly instead of forcing entries. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..44fbbaf0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## Unreleased + +### Added +- Added optional sound notifications for agent state changes, including a completion chime when background work finishes and an alert when an agent needs input. +- Added per-agent sound overrides under `[ui.sound.agents]`, so you can mute or enable notifications by agent instead of using one global setting. Droid notifications are muted by default. + +### Changed +- Request alerts now play even when the agent is in the active workspace, while completion sounds remain limited to background workspaces. + +### Fixed +- Improved foreground job detection on Linux and macOS so herdr can recognize agents that run through wrapper processes or generic runtimes, including cases like Codex running under `node`. +- Made Claude Code state detection more stable by handling more spinner variants and smoothing short busy/idle flicker during screen updates. + +## [0.1.0] - 2026-03-27 + +### Added +- Initial release. diff --git a/justfile b/justfile index e301cbd1..0c400b27 100644 --- a/justfile +++ b/justfile @@ -3,11 +3,13 @@ # Run unit tests test: cargo test + python3 -m unittest scripts.test_changelog # Check formatting + run unit tests check: cargo fmt --check cargo test + python3 -m unittest scripts.test_changelog # Run integration tests (LLM-based, requires pi + tmux) test-integration: @@ -29,7 +31,7 @@ clean-tests: @rm -f tests/integration/results/*.json tests/integration/results/*.txt 2>/dev/null || true @echo "cleaned" -# Bump version, commit, tag, push, trigger release build (usage: just release 0.1.1) +# Finalize changelog, bump version, commit, tag, push, trigger release build (usage: just release 0.1.1) release version: @if [ -n "$(git status --porcelain)" ]; then \ echo "error: commit your changes first"; \ @@ -39,9 +41,11 @@ release version: echo "error: tag v{{version}} already exists"; \ exit 1; \ fi + python3 scripts/changelog.py prepare --version {{version}} sed -i.bak 's/^version = ".*"/version = "{{version}}"/' Cargo.toml && rm -f Cargo.toml.bak cargo test --quiet - git add Cargo.toml Cargo.lock + python3 -m unittest scripts.test_changelog + git add CHANGELOG.md Cargo.toml Cargo.lock git diff --cached --quiet || git commit -m "release: v{{version}}" git tag v{{version}} git push --follow-tags diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 00000000..5a87d465 --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +SECTION_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P.+?))\s*$", re.MULTILINE) +VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") + + +@dataclass(frozen=True) +class Section: + title: str + start: int + end: int + body_start: int + + +class ChangelogError(ValueError): + pass + + +def normalize_title(raw_title: str) -> str: + title = raw_title.strip() + match = VERSION_WITH_DATE_RE.match(title) + if match: + title = match.group("version").strip() + if title.startswith("[") and title.endswith("]"): + title = title[1:-1].strip() + return title + + +def parse_sections(text: str) -> list[Section]: + matches = list(SECTION_RE.finditer(text)) + sections: list[Section] = [] + + for index, match in enumerate(matches): + title = normalize_title(match.group("bracketed") or match.group("plain") or "") + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body_start = match.end() + if body_start < len(text) and text[body_start : body_start + 1] == "\n": + body_start += 1 + sections.append(Section(title=title, start=match.start(), end=end, body_start=body_start)) + + return sections + + +def find_section(text: str, wanted_title: str) -> Section: + for section in parse_sections(text): + if section.title == wanted_title: + return section + raise ChangelogError(f"section not found: {wanted_title}") + + +def extract_section_body(text: str, wanted_title: str) -> str: + section = find_section(text, wanted_title) + body = text[section.body_start : section.end].strip("\n") + if not body.strip(): + raise ChangelogError(f"section is empty: {wanted_title}") + return body + "\n" + + +def prepare_release(text: str, version: str, release_date: str) -> str: + unreleased = None + existing_version = False + + for section in parse_sections(text): + if section.title == "Unreleased": + unreleased = section + if section.title == version: + existing_version = True + + if existing_version: + raise ChangelogError(f"version already exists in changelog: {version}") + if unreleased is None: + raise ChangelogError("missing Unreleased section") + + unreleased_body = text[unreleased.body_start : unreleased.end].strip("\n") + if not unreleased_body.strip(): + raise ChangelogError("Unreleased section is empty") + + prefix = text[: unreleased.start].rstrip("\n") + suffix = text[unreleased.end :].strip("\n") + + rebuilt = f"## Unreleased\n\n## [{version}] - {release_date}\n\n{unreleased_body}" + if suffix: + rebuilt += f"\n\n{suffix}" + + if prefix: + return f"{prefix}\n\n{rebuilt}\n" + return rebuilt + "\n" + + +def load_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"changelog not found: {path}") from exc + + +def write_text(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + + +def cmd_prepare(args: argparse.Namespace) -> int: + path = Path(args.path) + original = load_text(path) + updated = prepare_release(original, args.version, args.date) + write_text(path, updated) + return 0 + + +def cmd_extract(args: argparse.Namespace) -> int: + path = Path(args.path) + body = extract_section_body(load_text(path), args.version) + if args.output: + write_text(Path(args.output), body) + else: + sys.stdout.write(body) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes") + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser("prepare", help="Move Unreleased into a versioned section") + prepare.add_argument("--path", default="CHANGELOG.md") + prepare.add_argument("--version", required=True) + prepare.add_argument("--date", default=str(date.today())) + prepare.set_defaults(func=cmd_prepare) + + extract = subparsers.add_parser("extract", help="Extract a version section body") + extract.add_argument("--path", default="CHANGELOG.md") + extract.add_argument("--version", required=True) + extract.add_argument("--output") + extract.set_defaults(func=cmd_extract) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + return args.func(args) + except ChangelogError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py new file mode 100644 index 00000000..cf9e23ac --- /dev/null +++ b/scripts/test_changelog.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest + +from scripts.changelog import extract_section_body, prepare_release + + +class ChangelogScriptTests(unittest.TestCase): + def test_prepare_release_moves_unreleased_into_versioned_section(self) -> None: + original = """# Changelog\n\n## Unreleased\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n""" + + updated = prepare_release(original, "0.1.1", "2026-03-28") + + self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated) + self.assertIn("### Fixed\n- Smoothed Claude flapping.", updated) + self.assertIn("## [0.1.0] - 2026-03-27", updated) + + def test_prepare_release_accepts_bracketed_unreleased_heading(self) -> None: + original = """# Changelog\n\n## [Unreleased]\n\n### Added\n- Added sounds.\n""" + + updated = prepare_release(original, "0.1.1", "2026-03-28") + + self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated) + self.assertIn("### Added\n- Added sounds.", updated) + + def test_extract_section_body_returns_requested_version_only(self) -> None: + changelog = """# Changelog\n\n## Unreleased\n\n## [0.1.1] - 2026-03-28\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n""" + + body = extract_section_body(changelog, "0.1.1") + + self.assertEqual(body, "### Fixed\n- Smoothed Claude flapping.\n") + + +if __name__ == "__main__": + unittest.main()