From 5f234b1b3100324b4599481b7108e3b3c0ec3a94 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:28:21 -0700 Subject: [PATCH 1/3] Switch changelog to per-PR fragment files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every open PR editing CHANGELOG.md's `## Pending` section directly meant any two concurrent PRs collided on the same lines, and each merge forced the next PR to redo the conflict resolution. Add changelog.d/, where each PR drops a new file instead of editing the shared section, so concurrent PRs can never conflict on this axis. scripts/assemble_changelog.py folds the fragments into a versioned CHANGELOG.md section at release time. See changelog.d/README.md for the fragment format and .claude/commands/release.md for the updated release process (not committed here — .claude/ isn't tracked in this repo yet). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/README.md | 40 ++++++++++++ scripts/assemble_changelog.py | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 changelog.d/README.md create mode 100755 scripts/assemble_changelog.py diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..777f32a --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,40 @@ +# Changelog fragments + +`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR +edited directly. That meant any two PRs open at the same time collided on +the same few lines, and every merge forced the next PR to re-resolve the +conflict — annoying on its own, and it got worse the more PRs were open at +once. + +Instead, each PR that needs a changelog entry adds **a new file** here +instead of editing `CHANGELOG.md`. Two PRs adding two different files can +never conflict with each other, no matter what order they merge in. + +## Adding an entry + +Create `changelog.d/.md` with the same shape a normal +changelog section would have — one or more `### Added` / `### Changed` / +`### Fixed` / `### Removed` headings, each followed by one or more bullet +points: + +```markdown +### Fixed + +- `scout widgets list` no longer crashes on an empty response (#42) +``` + +A single fragment can include more than one heading if a PR touches more +than one kind of change. Reference the issue or PR number in the bullet +text the same way existing changelog entries do. + +## Releasing + +At release time, `scripts/assemble_changelog.py ` reads every +fragment in this directory, merges same-type sections together (in +Added / Changed / Fixed / Removed order), writes the result into +`CHANGELOG.md` under a new `## [] - ` heading, and deletes +the fragment files it consumed. See `.claude/commands/release.md` for the +full release process. + +Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an +in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py new file mode 100755 index 0000000..fbe9f99 --- /dev/null +++ b/scripts/assemble_changelog.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. + +Reads every fragment in changelog.d/ (except README.md), merges their +### Added / ### Changed / ### Fixed / ### Removed sections (in that order, +de-duplicating identical bullets), writes the result into CHANGELOG.md as a +new versioned section right after the "# Changelog" heading, and deletes +the fragment files it consumed. + +Usage: + python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] + +Run this as part of cutting a release (see .claude/commands/release.md). +Review the resulting CHANGELOG.md diff before committing — this script +does not commit anything itself. +""" +import argparse +import datetime +import re +import sys +from pathlib import Path + +ORDER = ["Added", "Changed", "Fixed", "Removed"] +REPO_ROOT = Path(__file__).resolve().parent.parent +FRAGMENTS_DIR = REPO_ROOT / "changelog.d" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + + +def parse_fragment(text): + """Return {heading: [bullet_lines]} from one fragment's text.""" + sections = {} + current = None + for line in text.splitlines(): + m = re.match(r"^### (\w+)", line) + if m: + current = m.group(1) + sections.setdefault(current, []) + continue + if current and line.strip().startswith("-"): + sections[current].append(line.rstrip()) + return sections + + +def render(sections): + out = [] + for heading in ORDER: + bullets = sections.get(heading) + if not bullets: + continue + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + # Preserve any non-standard heading at the end rather than silently + # dropping it. + for heading, bullets in sections.items(): + if heading not in ORDER and bullets: + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + return "".join(out) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Version being released, e.g. 0.5.0") + parser.add_argument( + "--date", + default=datetime.date.today().isoformat(), + help="Release date (default: today, YYYY-MM-DD)", + ) + args = parser.parse_args() + + fragment_paths = sorted( + p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" + ) + if not fragment_paths: + print("No changelog fragments found in changelog.d/ — nothing to do.") + sys.exit(0) + + merged = {} + for path in fragment_paths: + sections = parse_fragment(path.read_text()) + if not sections: + print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) + continue + for heading, bullets in sections.items(): + existing = merged.setdefault(heading, []) + for b in bullets: + if b not in existing: + existing.append(b) + + if not merged: + print("Fragments found but none contained usable entries — nothing to do.") + sys.exit(0) + + body = render(merged) + new_section = f"## [{args.version}] - {args.date}\n\n{body}" + + changelog = CHANGELOG_PATH.read_text() + lines = changelog.splitlines(keepends=True) + # Insert right after the "# Changelog" heading (and the blank line after + # it, if present), before the first existing "## " section. + insert_at = 1 + while insert_at < len(lines) and not lines[insert_at].startswith("## "): + insert_at += 1 + new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) + CHANGELOG_PATH.write_text(new_changelog) + + for path in fragment_paths: + path.unlink() + + print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") + print(f"Consumed {len(fragment_paths)} fragment(s):") + for path in fragment_paths: + print(f" - {path.relative_to(REPO_ROOT)}") + print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") + + +if __name__ == "__main__": + main() From 105ac3664bef559150bd8623b6d105bf45b9ad47 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:34:27 -0700 Subject: [PATCH 2/3] Fix 404ing documentation links in `scout setup` Every framework's docs link was built as `https://scoutapm.com/docs/`, but Scout's docs are organized by language, so all 11 links 404'd. Each framework entry now carries its own verified URL. Four frameworks have no dedicated docs page, so they point at the closest working one: - rails -> /docs/ruby (the Ruby agent install guide) - phoenix -> /docs/elixir (the Elixir agent install guide) - sidekiq -> /docs/ruby#instrumented-libraries - dramatiq -> /docs/python/other-libraries#dramatiq All URLs verified at HTTP 200 with no redirect. Closes #27 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/27.md | 3 +++ cmd/setup.go | 50 +++++++++++++++++++++------------------ cmd/setup_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 changelog.d/27.md create mode 100644 cmd/setup_test.go diff --git a/changelog.d/27.md b/changelog.d/27.md new file mode 100644 index 0000000..37986dc --- /dev/null +++ b/changelog.d/27.md @@ -0,0 +1,3 @@ +### Fixed + +- `scout setup ` now links to a real docs page for every framework — the old `/docs/` URLs all 404'd because Scout's docs are organized by language (#27) diff --git a/cmd/setup.go b/cmd/setup.go index c8d0111..8c6f37a 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -7,21 +7,25 @@ import ( "github.com/spf13/cobra" ) +// Scout's docs are organized by language, not by framework, and several +// frameworks have no dedicated page — so each entry carries its own URL +// rather than deriving one from the framework name. var frameworks = []struct { - name string - desc string + name string + desc string + docsURL string }{ - {"rails", "Ruby on Rails"}, - {"django", "Python Django"}, - {"flask", "Python Flask"}, - {"phoenix", "Elixir Phoenix"}, - {"express", "Node.js Express"}, - {"laravel", "PHP Laravel"}, - {"sinatra", "Ruby Sinatra"}, - {"fastapi", "Python FastAPI"}, - {"celery", "Python Celery"}, - {"dramatiq", "Python Dramatiq"}, - {"sidekiq", "Ruby Sidekiq"}, + {"rails", "Ruby on Rails", "https://scoutapm.com/docs/ruby"}, + {"django", "Python Django", "https://scoutapm.com/docs/python/django"}, + {"flask", "Python Flask", "https://scoutapm.com/docs/python/flask"}, + {"phoenix", "Elixir Phoenix", "https://scoutapm.com/docs/elixir"}, + {"express", "Node.js Express", "https://scoutapm.com/docs/node/express"}, + {"laravel", "PHP Laravel", "https://scoutapm.com/docs/php/laravel"}, + {"sinatra", "Ruby Sinatra", "https://scoutapm.com/docs/ruby/sinatra"}, + {"fastapi", "Python FastAPI", "https://scoutapm.com/docs/python/fastapi"}, + {"celery", "Python Celery", "https://scoutapm.com/docs/python/celery"}, + {"dramatiq", "Python Dramatiq", "https://scoutapm.com/docs/python/other-libraries#dramatiq"}, + {"sidekiq", "Ruby Sidekiq", "https://scoutapm.com/docs/ruby#instrumented-libraries"}, } var setupCmd = &cobra.Command{ @@ -35,6 +39,15 @@ func init() { rootCmd.AddCommand(setupCmd) } +func frameworkDocsURL(name string) (string, bool) { + for _, f := range frameworks { + if f.name == name { + return f.docsURL, true + } + } + return "", false +} + func runSetup(cmd *cobra.Command, args []string) { if len(args) == 0 { names := make([]string, len(frameworks)) @@ -59,20 +72,11 @@ func runSetup(cmd *cobra.Command, args []string) { } framework := args[0] - found := false - for _, f := range frameworks { - if f.name == framework { - found = true - break - } - } - + docsURL, found := frameworkDocsURL(framework) if !found { exitError(fmt.Sprintf("unknown framework: %s", framework)) } - docsURL := fmt.Sprintf("https://scoutapm.com/docs/%s", framework) - if structuredOutput(map[string]interface{}{ "framework": framework, "docs_url": docsURL, diff --git a/cmd/setup_test.go b/cmd/setup_test.go new file mode 100644 index 0000000..ad0843b --- /dev/null +++ b/cmd/setup_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrameworkTableIsWellFormed(t *testing.T) { + seen := make(map[string]bool, len(frameworks)) + + for _, f := range frameworks { + t.Run(f.name, func(t *testing.T) { + assert.NotEmpty(t, f.name) + assert.NotEmpty(t, f.desc) + assert.False(t, seen[f.name], "duplicate framework entry") + seen[f.name] = true + + u, err := url.Parse(f.docsURL) + require.NoError(t, err) + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "scoutapm.com", u.Host) + assert.True(t, strings.HasPrefix(u.Path, "/docs/"), "docs URL should live under /docs/: %s", f.docsURL) + + // Scout's docs are organized by language, so /docs/ 404s. + assert.NotEqual(t, "/docs/"+f.name, u.Path, "docs URL must not be derived from the framework name") + }) + } +} + +func TestFrameworkDocsURLLookup(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"rails", "https://scoutapm.com/docs/ruby"}, + {"django", "https://scoutapm.com/docs/python/django"}, + {"sinatra", "https://scoutapm.com/docs/ruby/sinatra"}, + {"express", "https://scoutapm.com/docs/node/express"}, + {"sidekiq", "https://scoutapm.com/docs/ruby#instrumented-libraries"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found := frameworkDocsURL(tt.name) + require.True(t, found) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFrameworkDocsURLUnknown(t *testing.T) { + got, found := frameworkDocsURL("cobol") + assert.False(t, found) + assert.Empty(t, got) +} From 5dd2a9828bf15b7c27dcd66e0d35f02ce5f4dcdf Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 11 Sep 2026 10:25:14 -0700 Subject: [PATCH 3/3] Revert to direct CHANGELOG.md edits Drop changelog.d/ and the assembly script. Move this PR's changelog entry into CHANGELOG.md's Pending section directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- CHANGELOG.md | 6 ++ changelog.d/27.md | 3 - changelog.d/README.md | 40 ------------ scripts/assemble_changelog.py | 120 ---------------------------------- 4 files changed, 6 insertions(+), 163 deletions(-) delete mode 100644 changelog.d/27.md delete mode 100644 changelog.d/README.md delete mode 100755 scripts/assemble_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bc03b..3e88c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Pending + +### Fixed + +- `scout setup ` now links to a real docs page for every framework — the old `/docs/` URLs all 404'd because Scout's docs are organized by language (#27) + ## [0.5.0] - 2026-09-10 This release rounds out command coverage across the whole Scout API surface diff --git a/changelog.d/27.md b/changelog.d/27.md deleted file mode 100644 index 37986dc..0000000 --- a/changelog.d/27.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed - -- `scout setup ` now links to a real docs page for every framework — the old `/docs/` URLs all 404'd because Scout's docs are organized by language (#27) diff --git a/changelog.d/README.md b/changelog.d/README.md deleted file mode 100644 index 777f32a..0000000 --- a/changelog.d/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog fragments - -`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR -edited directly. That meant any two PRs open at the same time collided on -the same few lines, and every merge forced the next PR to re-resolve the -conflict — annoying on its own, and it got worse the more PRs were open at -once. - -Instead, each PR that needs a changelog entry adds **a new file** here -instead of editing `CHANGELOG.md`. Two PRs adding two different files can -never conflict with each other, no matter what order they merge in. - -## Adding an entry - -Create `changelog.d/.md` with the same shape a normal -changelog section would have — one or more `### Added` / `### Changed` / -`### Fixed` / `### Removed` headings, each followed by one or more bullet -points: - -```markdown -### Fixed - -- `scout widgets list` no longer crashes on an empty response (#42) -``` - -A single fragment can include more than one heading if a PR touches more -than one kind of change. Reference the issue or PR number in the bullet -text the same way existing changelog entries do. - -## Releasing - -At release time, `scripts/assemble_changelog.py ` reads every -fragment in this directory, merges same-type sections together (in -Added / Changed / Fixed / Removed order), writes the result into -`CHANGELOG.md` under a new `## [] - ` heading, and deletes -the fragment files it consumed. See `.claude/commands/release.md` for the -full release process. - -Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an -in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py deleted file mode 100755 index fbe9f99..0000000 --- a/scripts/assemble_changelog.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. - -Reads every fragment in changelog.d/ (except README.md), merges their -### Added / ### Changed / ### Fixed / ### Removed sections (in that order, -de-duplicating identical bullets), writes the result into CHANGELOG.md as a -new versioned section right after the "# Changelog" heading, and deletes -the fragment files it consumed. - -Usage: - python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] - -Run this as part of cutting a release (see .claude/commands/release.md). -Review the resulting CHANGELOG.md diff before committing — this script -does not commit anything itself. -""" -import argparse -import datetime -import re -import sys -from pathlib import Path - -ORDER = ["Added", "Changed", "Fixed", "Removed"] -REPO_ROOT = Path(__file__).resolve().parent.parent -FRAGMENTS_DIR = REPO_ROOT / "changelog.d" -CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" - - -def parse_fragment(text): - """Return {heading: [bullet_lines]} from one fragment's text.""" - sections = {} - current = None - for line in text.splitlines(): - m = re.match(r"^### (\w+)", line) - if m: - current = m.group(1) - sections.setdefault(current, []) - continue - if current and line.strip().startswith("-"): - sections[current].append(line.rstrip()) - return sections - - -def render(sections): - out = [] - for heading in ORDER: - bullets = sections.get(heading) - if not bullets: - continue - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - # Preserve any non-standard heading at the end rather than silently - # dropping it. - for heading, bullets in sections.items(): - if heading not in ORDER and bullets: - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - return "".join(out) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Version being released, e.g. 0.5.0") - parser.add_argument( - "--date", - default=datetime.date.today().isoformat(), - help="Release date (default: today, YYYY-MM-DD)", - ) - args = parser.parse_args() - - fragment_paths = sorted( - p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" - ) - if not fragment_paths: - print("No changelog fragments found in changelog.d/ — nothing to do.") - sys.exit(0) - - merged = {} - for path in fragment_paths: - sections = parse_fragment(path.read_text()) - if not sections: - print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) - continue - for heading, bullets in sections.items(): - existing = merged.setdefault(heading, []) - for b in bullets: - if b not in existing: - existing.append(b) - - if not merged: - print("Fragments found but none contained usable entries — nothing to do.") - sys.exit(0) - - body = render(merged) - new_section = f"## [{args.version}] - {args.date}\n\n{body}" - - changelog = CHANGELOG_PATH.read_text() - lines = changelog.splitlines(keepends=True) - # Insert right after the "# Changelog" heading (and the blank line after - # it, if present), before the first existing "## " section. - insert_at = 1 - while insert_at < len(lines) and not lines[insert_at].startswith("## "): - insert_at += 1 - new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) - CHANGELOG_PATH.write_text(new_changelog) - - for path in fragment_paths: - path.unlink() - - print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") - print(f"Consumed {len(fragment_paths)} fragment(s):") - for path in fragment_paths: - print(f" - {path.relative_to(REPO_ROOT)}") - print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") - - -if __name__ == "__main__": - main()