Skip to content

Commit f925b28

Browse files
JohnCampionJrclaude
andcommitted
Add changelog-git and changelog-github generators
Support the `changelog` config key (a generator name string or `["@changesets/changelog-github", { "repo": "owner/repo" }]`): - changelog-git prepends the short hash of the commit that added each changeset. - changelog-github adds the PR link, commit link, and `Thanks @author!`, resolving PR/author via the GitHub CLI (`gh`) and degrading gracefully when git/gh data is unavailable. A commit resolver (git + gh via the process executor) feeds per-changeset commit facts into the changelog generator, which renders the first line of each entry accordingly. The default format and dependency-update lines are unchanged. README/docs/ROADMAP updated; changeset added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8a51c02 commit f925b28

18 files changed

Lines changed: 724 additions & 20 deletions

.changeset/changelog-generators.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"SolarWinds.Changesets": Minor
3+
---
4+
5+
Support the `changelog-git` and `changelog-github` changelog generators, selected via the shared `changelog` config key (a generator name string, or `["@changesets/changelog-github", { "repo": "owner/repo" }]`). `changelog-git` prepends the short hash of the commit that added each changeset; `changelog-github` adds the pull-request link, commit link, and `Thanks @author!`, resolving PR and author data through the GitHub CLI (`gh`) and degrading gracefully when git/`gh` data is unavailable. The default format is unchanged, and dependency-update lines continue to use it.

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ This .NET implementation is port from original `npm` implementation [@changesets
3636

3737
## Differences from @changesets
3838

39-
net-changesets aims for parity with [@changesets](https://github.com/changesets/changesets), with a few
40-
intentional gaps:
41-
42-
- **Changelog formatters.** The changelog matches the default `@changesets` format (the changeset summary as
43-
the entry). The optional `changelog-git` (prepends the short commit hash, e.g. `- a1b2c3d: <summary>`) and
44-
`changelog-github` (PR links + commit links + author thanks) generators are not implemented. They may be
45-
added in a future update if needed or requested.
39+
net-changesets aims for parity with [@changesets](https://github.com/changesets/changesets). The default
40+
changelog format, plus the `changelog-git` (short commit-hash prefix) and `changelog-github` (PR links, commit
41+
links, and author thanks) generators are supported, selected via the
42+
[`changelog` config key](docs/config-file-options.md#changelog). A couple of small differences remain:
43+
44+
- **Dependency-update changelog lines** always use the default format, even under `changelog-git` /
45+
`changelog-github` (the per-package entries do honour the selected generator).
46+
- **`changelog-github`** resolves PR and author data through the GitHub CLI (`gh`) rather than a built-in API
47+
client, and degrades gracefully when `gh` or git history is unavailable.
4648

4749
## Using alongside @changesets (Node)
4850

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ the Node tool so a single action drives both ecosystems.
2020

2121
## Planned features
2222

23-
- **Changelog generators.** Beyond the default format, support the `changelog-git` (commit-hash prefix) and
24-
`changelog-github` (PR links, commit links, and author thanks) generators, selected via the `changelog`
25-
config key.
23+
No specific features are planned right now. There is room to deepen `@changesets` parity (for example,
24+
applying the selected changelog generator to dependency-update lines, or a built-in GitHub API client so
25+
`changelog-github` does not depend on the `gh` CLI).
2626

2727
Have an idea or suggestion? [Open a GitHub issue](./issues) to start a discussion.

docs/config-file-options.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ Packages excluded from versioning. An array of project names or globs to skip, e
5656
The formatter run over generated changelogs: a formatter name (`prettier`, `dprint`, `biome`, `oxfmt`, `deno`),
5757
`"auto"` to detect one from the repo, or `false` (the default) to skip formatting.
5858

59+
### `changelog`
60+
61+
The changelog generator. Defaults to the standard format (the changeset summary as the entry). net also
62+
supports:
63+
64+
- `"@changesets/changelog-git"` — prepends the short hash of the commit that added the changeset, e.g.
65+
`- a1b2c3d: <summary>`.
66+
- `["@changesets/changelog-github", { "repo": "owner/repo" }]` — adds the pull-request link, commit link, and
67+
`Thanks @author!`, e.g. `- [#12](…) [`a1b2c3d`](…) Thanks [@octocat](…)! - <summary>`. Requires the
68+
[GitHub CLI](https://cli.github.com/) (`gh`) on `PATH`, authenticated (it is preinstalled and authenticated
69+
on GitHub Actions runners). When `gh` or git data is unavailable, the entry degrades gracefully to the
70+
available parts.
71+
72+
`false` or `"@changesets/cli/changelog"` both select the default format. Dependency-update lines always use
73+
the default format.
74+
5975
### `snapshot`
6076

6177
Snapshot-release settings, matching `@changesets`:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using SolarWinds.Changesets.Shared;
2+
3+
namespace SolarWinds.Changesets.Commands.Version.Helpers;
4+
5+
/// <summary>Resolves the commit (and, for github, PR and author) that introduced each changeset.</summary>
6+
internal interface IChangelogCommitResolver
7+
{
8+
Task<IReadOnlyDictionary<string, ChangesetCommitInfo>> ResolveAsync(
9+
IReadOnlyCollection<string> changesetIds, ChangelogSetting setting, string workingDirectory);
10+
}
11+
12+
/// <summary>
13+
/// Resolves changelog commit facts using git and the GitHub CLI. <c>git log</c> finds the commit that added a
14+
/// changeset file; for the github generator <c>gh api</c> looks up the associated pull request and author.
15+
/// Any lookup that fails (no git history, <c>gh</c> not installed or unauthenticated) yields a null field, so
16+
/// the changelog degrades gracefully rather than erroring.
17+
/// </summary>
18+
internal sealed class ChangelogCommitResolver : IChangelogCommitResolver
19+
{
20+
private static readonly string[] s_extensions = [".md", ".net.mkd"];
21+
22+
private readonly IProcessExecutor _processExecutor;
23+
24+
public ChangelogCommitResolver(IProcessExecutor processExecutor)
25+
{
26+
_processExecutor = processExecutor;
27+
}
28+
29+
public async Task<IReadOnlyDictionary<string, ChangesetCommitInfo>> ResolveAsync(
30+
IReadOnlyCollection<string> changesetIds, ChangelogSetting setting, string workingDirectory)
31+
{
32+
Dictionary<string, ChangesetCommitInfo> result = new(StringComparer.Ordinal);
33+
34+
if (setting.Kind == ChangelogKind.Default)
35+
{
36+
return result;
37+
}
38+
39+
foreach (string id in changesetIds.Distinct(StringComparer.Ordinal))
40+
{
41+
string? shortHash = await CommitThatAddedChangesetAsync(id, "%h", workingDirectory);
42+
if (shortHash is null)
43+
{
44+
continue;
45+
}
46+
47+
int? pullRequest = null;
48+
string? author = null;
49+
if (setting.Kind == ChangelogKind.GitHub && !string.IsNullOrEmpty(setting.Repo))
50+
{
51+
string? fullHash = await CommitThatAddedChangesetAsync(id, "%H", workingDirectory);
52+
if (fullHash is not null)
53+
{
54+
pullRequest = await PullRequestForCommitAsync(setting.Repo, fullHash, workingDirectory);
55+
author = await AuthorForCommitAsync(setting.Repo, fullHash, workingDirectory);
56+
}
57+
}
58+
59+
result[id] = new ChangesetCommitInfo(shortHash, pullRequest, author);
60+
}
61+
62+
return result;
63+
}
64+
65+
private async Task<string?> CommitThatAddedChangesetAsync(string id, string format, string workingDirectory)
66+
{
67+
foreach (string extension in s_extensions)
68+
{
69+
string? hash = await RunFirstLineAsync(
70+
"git", $"log --diff-filter=A --max-count=1 --format={format} -- \".changeset/{id}{extension}\"", workingDirectory);
71+
if (hash is not null)
72+
{
73+
return hash;
74+
}
75+
}
76+
77+
return null;
78+
}
79+
80+
private async Task<int?> PullRequestForCommitAsync(string repo, string sha, string workingDirectory)
81+
{
82+
string? value = await RunFirstLineAsync(
83+
"gh", $"api repos/{repo}/commits/{sha}/pulls --jq \".[0].number\"", workingDirectory);
84+
return int.TryParse(value, out int number) ? number : null;
85+
}
86+
87+
private async Task<string?> AuthorForCommitAsync(string repo, string sha, string workingDirectory) =>
88+
await RunFirstLineAsync("gh", $"api repos/{repo}/commits/{sha} --jq \".author.login\"", workingDirectory);
89+
90+
private async Task<string?> RunFirstLineAsync(string executable, string arguments, string workingDirectory)
91+
{
92+
ProcessOutput result;
93+
try
94+
{
95+
result = await _processExecutor.Execute(executable, arguments, workingDirectory);
96+
}
97+
catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception)
98+
{
99+
return null;
100+
}
101+
102+
if (result.ExitCode != 0)
103+
{
104+
return null;
105+
}
106+
107+
string? line = result.Output
108+
.Select(value => value.Trim())
109+
.FirstOrDefault(value => value.Length > 0 && !string.Equals(value, "null", StringComparison.Ordinal));
110+
111+
return string.IsNullOrEmpty(line) ? null : line;
112+
}
113+
}

src/SolarWinds.Changesets/Commands/Version/Helpers/ChangelogGenerator.cs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@ internal sealed class ChangelogGenerator : IChangelogGenerator
77
{
88
private const string DependencyUpdatesHeader = "Updated dependencies";
99

10+
private static readonly IReadOnlyDictionary<string, ChangesetCommitInfo> s_noCommitInfo =
11+
new Dictionary<string, ChangesetCommitInfo>(StringComparer.Ordinal);
12+
13+
/// <inheritdoc/>
14+
public IEnumerable<ModuleChangelog> GetProcessedChangelogs(
15+
ChangesetFile[] changesets, IEnumerable<CsProject> csProjects, ChangesetConfig config) =>
16+
GetProcessedChangelogs(changesets, csProjects, config, s_noCommitInfo);
17+
1018
/// <inheritdoc/>
1119
public IEnumerable<ModuleChangelog> GetProcessedChangelogs(
12-
ChangesetFile[] changesets, IEnumerable<CsProject> csProjects, ChangesetConfig config)
20+
ChangesetFile[] changesets,
21+
IEnumerable<CsProject> csProjects,
22+
ChangesetConfig config,
23+
IReadOnlyDictionary<string, ChangesetCommitInfo> commitInfo)
1324
{
14-
List<ModuleChangelog> moduleChangelogs = GenerateModuleChangelogs(changesets, csProjects);
25+
List<ModuleChangelog> moduleChangelogs = GenerateModuleChangelogs(changesets, csProjects, config.Changelog, commitInfo);
1526

1627
ImmutableDictionary<string, CsProject[]> csProjectToDependentProjects = GetProjectsDependentsNames(csProjects);
1728

@@ -168,18 +179,25 @@ private static Semver GetCurrentHighestVersion(IReadOnlyList<string> group, IEnu
168179
private static bool IsHigher(Semver candidate, Semver current) => candidate.CompareTo(current) > 0;
169180

170181
/// <summary>
171-
/// Processes changeset files and filters out unique modules with their <see cref="ModuleChangelog"/>
182+
/// Processes changeset files and filters out unique modules with their <see cref="ModuleChangelog"/>,
183+
/// applying the configured changelog generator to each summary.
172184
/// </summary>
173-
/// <param name="changesetFiles"></param>
174-
/// <param name="csProjects"></param>
175-
/// <returns>Collection of <see cref="ModuleChangelog"/></returns>
176185
private static List<ModuleChangelog> GenerateModuleChangelogs(
177-
ChangesetFile[] changesetFiles, IEnumerable<CsProject> csProjects)
186+
ChangesetFile[] changesetFiles,
187+
IEnumerable<CsProject> csProjects,
188+
ChangelogSetting changelogSetting,
189+
IReadOnlyDictionary<string, ChangesetCommitInfo> commitInfo)
178190
{
179191
Dictionary<string, ModuleChangelog> moduleChangelogs = [];
180192

181193
foreach (ChangesetFile changesetFile in changesetFiles)
182194
{
195+
// Apply the configured generator (default/git/github) to the summary using this changeset's commit.
196+
ChangesetCommitInfo? info = changesetFile.Id is not null && commitInfo.TryGetValue(changesetFile.Id, out ChangesetCommitInfo? resolved)
197+
? resolved
198+
: null;
199+
string description = ChangelogReleaseLine.Render(changesetFile.Description, info, changelogSetting);
200+
183201
foreach (ChangesetRelease release in changesetFile.Releases)
184202
{
185203
if (!moduleChangelogs.TryGetValue(release.Name, out ModuleChangelog? moduleChangelog))
@@ -203,7 +221,7 @@ private static List<ModuleChangelog> GenerateModuleChangelogs(
203221
moduleChangelogs[release.Name] = moduleChangelog;
204222
}
205223

206-
moduleChangelog.Changes.Add((changesetFile.Description, release.Bump));
224+
moduleChangelog.Changes.Add((description, release.Bump));
207225
}
208226
}
209227

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using SolarWinds.Changesets.Shared;
4+
5+
namespace SolarWinds.Changesets.Commands.Version.Helpers;
6+
7+
/// <summary>
8+
/// Applies the configured changelog generator to a changeset summary, mirroring <c>@changesets</c>'
9+
/// <c>getReleaseLine</c>. It only transforms the first line (prepending a commit hash, or PR/commit/author
10+
/// links); continuation lines and the bullet itself are added later by the changelog writer.
11+
/// </summary>
12+
internal static class ChangelogReleaseLine
13+
{
14+
public static string Render(string summary, ChangesetCommitInfo? info, ChangelogSetting setting)
15+
{
16+
string prefix = setting.Kind switch
17+
{
18+
ChangelogKind.Git => GitPrefix(info),
19+
ChangelogKind.GitHub => GitHubPrefix(info, setting.Repo),
20+
_ => string.Empty,
21+
};
22+
23+
if (prefix.Length == 0)
24+
{
25+
return summary;
26+
}
27+
28+
int newline = summary.IndexOf('\n', StringComparison.Ordinal);
29+
return newline < 0 ? prefix + summary : string.Concat(prefix, summary.AsSpan(0, newline), summary.AsSpan(newline));
30+
}
31+
32+
// @changesets/changelog-git: "<commit>: <summary>".
33+
private static string GitPrefix(ChangesetCommitInfo? info) =>
34+
info?.Commit is { Length: > 0 } commit ? $"{commit}: " : string.Empty;
35+
36+
// @changesets/changelog-github: "[#pr](url) [`commit`](url) Thanks [@user](url)! - <summary>".
37+
private static string GitHubPrefix(ChangesetCommitInfo? info, string? repo)
38+
{
39+
if (info is null || string.IsNullOrEmpty(repo))
40+
{
41+
return string.Empty;
42+
}
43+
44+
string? pullLink = info.PullRequest is { } pr
45+
? $"[#{pr.ToString(CultureInfo.InvariantCulture)}](https://github.com/{repo}/pull/{pr.ToString(CultureInfo.InvariantCulture)})"
46+
: null;
47+
string? commitLink = info.Commit is { Length: > 0 } commit
48+
? $"[`{commit}`](https://github.com/{repo}/commit/{commit})"
49+
: null;
50+
string? userLink = info.Author is { Length: > 0 } author
51+
? $"[@{author}](https://github.com/{author})"
52+
: null;
53+
54+
if (pullLink is null && commitLink is null && userLink is null)
55+
{
56+
return string.Empty;
57+
}
58+
59+
StringBuilder prefix = new();
60+
if (pullLink is not null)
61+
{
62+
prefix.Append(pullLink).Append(' ');
63+
}
64+
65+
if (commitLink is not null)
66+
{
67+
prefix.Append(commitLink).Append(' ');
68+
}
69+
70+
prefix.Append("Thanks ");
71+
if (userLink is not null)
72+
{
73+
prefix.Append(userLink);
74+
}
75+
76+
prefix.Append("! - ");
77+
return prefix.ToString();
78+
}
79+
}

src/SolarWinds.Changesets/Commands/Version/Helpers/IChangelogGenerator.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,20 @@ internal interface IChangelogGenerator
1313
/// <returns>Collection of <see cref="ModuleChangelog"/></returns>
1414
IEnumerable<ModuleChangelog> GetProcessedChangelogs(
1515
ChangesetFile[] changesets, IEnumerable<CsProject> csProjects, ChangesetConfig config);
16+
17+
/// <summary>
18+
/// As <see cref="GetProcessedChangelogs(ChangesetFile[], IEnumerable{CsProject}, ChangesetConfig)"/>,
19+
/// additionally applying the configured changelog generator (git/github) using the resolved per-changeset
20+
/// commit facts.
21+
/// </summary>
22+
/// <param name="changesets">The changesets to process.</param>
23+
/// <param name="csProjects">The discovered projects.</param>
24+
/// <param name="config">The changeset configuration.</param>
25+
/// <param name="commitInfo">Per-changeset-id commit facts (commit, PR, author) for the git/github generators.</param>
26+
/// <returns>Collection of <see cref="ModuleChangelog"/></returns>
27+
IEnumerable<ModuleChangelog> GetProcessedChangelogs(
28+
ChangesetFile[] changesets,
29+
IEnumerable<CsProject> csProjects,
30+
ChangesetConfig config,
31+
IReadOnlyDictionary<string, ChangesetCommitInfo> commitInfo);
1632
}

src/SolarWinds.Changesets/Commands/Version/VersionChangesetCommand.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal sealed class VersionChangesetCommand : ConfigurationCommandBase<Version
2222
private readonly INodePackagesRepository _nodePackagesRepository;
2323
private readonly IPreStateRepository _preStateRepository;
2424
private readonly IGitService _gitService;
25+
private readonly IChangelogCommitResolver _commitResolver;
2526

2627
public static string Name { get; } = "version";
2728
public static string Description { get; }
@@ -38,7 +39,8 @@ public VersionChangesetCommand(
3839
INodeChangesetService nodeChangesetService,
3940
INodePackagesRepository nodePackagesRepository,
4041
IPreStateRepository preStateRepository,
41-
IGitService gitService
42+
IGitService gitService,
43+
IChangelogCommitResolver commitResolver
4244
) : base(configurationService)
4345
{
4446
_console = console;
@@ -51,6 +53,7 @@ IGitService gitService
5153
_nodePackagesRepository = nodePackagesRepository;
5254
_preStateRepository = preStateRepository;
5355
_gitService = gitService;
56+
_commitResolver = commitResolver;
5457
}
5558

5659
public override async Task<int> ExecuteCommandAsync(CommandContext context, VersionChangesetCommandSettings settings)
@@ -118,7 +121,13 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Vers
118121
csProjects = CsProjectStrategy.ToIndependent(csProjects);
119122
}
120123

121-
List<ModuleChangelog> changelogs = _changelogGenerator.GetProcessedChangelogs(relevantChangesets, csProjects, ChangesetConfig).ToList();
124+
IReadOnlyDictionary<string, ChangesetCommitInfo> commitInfo = await _commitResolver.ResolveAsync(
125+
relevantChangesets.Select(changeset => changeset.Id).OfType<string>().ToArray(),
126+
ChangesetConfig.Changelog,
127+
Constants.WorkingDirectoryFullPath);
128+
129+
List<ModuleChangelog> changelogs = _changelogGenerator
130+
.GetProcessedChangelogs(relevantChangesets, csProjects, ChangesetConfig, commitInfo).ToList();
122131

123132
if (snapshot)
124133
{

src/SolarWinds.Changesets/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
serviceCollection.AddSingleton<IChangesetsRepository, ChangesetsRepository>();
3333
serviceCollection.AddSingleton<IChangelogFileWriter, ChangelogFileWriter>();
3434
serviceCollection.AddSingleton<IChangelogGenerator, ChangelogGenerator>();
35+
serviceCollection.AddSingleton<IChangelogCommitResolver, ChangelogCommitResolver>();
3536
serviceCollection.AddSingleton<IChangelogFormatter, ChangelogFormatter>();
3637
serviceCollection.AddSingleton<ICsProjectsRepository, CsProjectsRepository>();
3738
serviceCollection.AddSingleton<INodePackagesRepository, NodePackagesRepository>();

0 commit comments

Comments
 (0)