Skip to content

Commit 4c468f2

Browse files
JohnCampionJrclaude
andcommitted
Extract a pure ReleasePlanner shared by version and status
Introduce IReleasePlanner.Assemble(ReleaseInputs) -> List<ModuleChangelog>: a pure transform that runs the changelog generator and folds in the version computation for the release mode (Normal/Pre/Exit/Snapshot), mirroring @changesets' shared assembleReleasePlan. The version command now gathers inputs and calls Assemble instead of mutating changelogs post-generation; only the impure snapshot-suffix resolution (commit id + clock) stays in the command. The pre/snapshot version application and prerelease graduation move into the planner. The status command now reads pre.json and routes through the same planner, so it reflects prerelease target versions like version does (and like the Node tool, whose status and version share getReleasePlan). Previously status ignored pre mode and could show the wrong versions. Add ReleasePlannerTests covering each mode with in-memory data; migrate the status command tests to mock IReleasePlanner + IPreStateRepository. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b1be8d commit 4c468f2

8 files changed

Lines changed: 323 additions & 69 deletions

File tree

src/SolarWinds.Changesets/Commands/Status/StatusChangesetCommand.cs

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ internal sealed class StatusChangesetCommand : ConfigurationCommandBase<StatusCh
2121
private readonly IAnsiConsole _console;
2222
private readonly IChangesetsRepository _changesetsRepository;
2323
private readonly ICsProjectsRepository _csProjectsRepository;
24-
private readonly IChangelogGenerator _changelogGenerator;
24+
private readonly IReleasePlanner _releasePlanner;
25+
private readonly IPreStateRepository _preStateRepository;
2526
private readonly IGitService _gitService;
2627
private readonly INodeChangesetService _nodeChangesetService;
2728
private readonly INodePackagesRepository _nodePackagesRepository;
@@ -43,7 +44,8 @@ internal sealed class StatusChangesetCommand : ConfigurationCommandBase<StatusCh
4344
/// <param name="console">The console.</param>
4445
/// <param name="changesetsRepository">The changesets repository.</param>
4546
/// <param name="csProjectsRepository">The .csproj repository.</param>
46-
/// <param name="changelogGenerator">The changelog generator used to compute the release plan.</param>
47+
/// <param name="releasePlanner">Assembles the release plan - the same planner the version command uses.</param>
48+
/// <param name="preStateRepository">Reads pre.json so status reflects prerelease versions, like the version command.</param>
4749
/// <param name="gitService">Reads files changed since a git ref for --since.</param>
4850
/// <param name="nodeChangesetService">Runs the Node tool when interop autoRunNode is enabled.</param>
4951
/// <param name="nodePackagesRepository">Discovers Node packages so interop status can group them.</param>
@@ -52,15 +54,17 @@ public StatusChangesetCommand(
5254
IAnsiConsole console,
5355
IChangesetsRepository changesetsRepository,
5456
ICsProjectsRepository csProjectsRepository,
55-
IChangelogGenerator changelogGenerator,
57+
IReleasePlanner releasePlanner,
58+
IPreStateRepository preStateRepository,
5659
IGitService gitService,
5760
INodeChangesetService nodeChangesetService,
5861
INodePackagesRepository nodePackagesRepository) : base(configurationService)
5962
{
6063
_console = console;
6164
_changesetsRepository = changesetsRepository;
6265
_csProjectsRepository = csProjectsRepository;
63-
_changelogGenerator = changelogGenerator;
66+
_releasePlanner = releasePlanner;
67+
_preStateRepository = preStateRepository;
6468
_gitService = gitService;
6569
_nodeChangesetService = nodeChangesetService;
6670
_nodePackagesRepository = nodePackagesRepository;
@@ -111,9 +115,33 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Stat
111115
csProjects = CsProjectStrategy.ToIndependent(csProjects);
112116
}
113117

114-
List<ModuleChangelog> changelogs = _changelogGenerator.GetProcessedChangelogs(changesets, csProjects, ChangesetConfig).ToList();
115-
Dictionary<string, List<string>> changesetsByModule = MapChangesetsToModules(changesets);
116-
Dictionary<string, BumpType> nodeBumps = ComputeNodeBumps(changesets, csProjects);
118+
// Reflect prerelease mode the same way the version command does, so status never shows a different target
119+
// version than the release that would follow. Snapshot has no status equivalent (there is no status --snapshot).
120+
PreState? preState = await _preStateRepository.ReadAsync(Constants.ChangesetDirectoryFullPath);
121+
ReleaseMode mode = preState switch
122+
{
123+
{ Mode: PreMode.Pre } => ReleaseMode.Pre,
124+
{ Mode: PreMode.Exit } => ReleaseMode.Exit,
125+
_ => ReleaseMode.Normal,
126+
};
127+
128+
// In pre mode a changeset is only released once; mirror version's filtering so the plan matches.
129+
ChangesetFile[] relevantChangesets = mode == ReleaseMode.Pre
130+
? changesets.Where(changeset => changeset.Id is null || !preState!.Changesets.Contains(changeset.Id, StringComparer.Ordinal)).ToArray()
131+
: changesets;
132+
133+
List<ModuleChangelog> changelogs = _releasePlanner.Assemble(new ReleaseInputs
134+
{
135+
Changesets = relevantChangesets,
136+
Projects = csProjects,
137+
Config = ChangesetConfig,
138+
Mode = mode,
139+
PreTag = mode == ReleaseMode.Pre ? preState!.Tag : null,
140+
});
141+
// Use the same relevant set as the plan so already-consumed (pre-mode) changesets are not listed as
142+
// pending changeset files or node bumps.
143+
Dictionary<string, List<string>> changesetsByModule = MapChangesetsToModules(relevantChangesets);
144+
Dictionary<string, BumpType> nodeBumps = ComputeNodeBumps(relevantChangesets, csProjects);
117145

118146
if (settings.Output is not null)
119147
{
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using SolarWinds.Changesets.Shared;
2+
3+
namespace SolarWinds.Changesets.Commands.Version.Helpers;
4+
5+
/// <summary>
6+
/// The kind of release being assembled, which determines how each module's final version is computed.
7+
/// </summary>
8+
internal enum ReleaseMode
9+
{
10+
/// <summary>A normal release: each module takes its next stable bump.</summary>
11+
Normal,
12+
13+
/// <summary>Prerelease mode: each module takes a <c>-{tag}.{n}</c> prerelease version.</summary>
14+
Pre,
15+
16+
/// <summary>Exiting prerelease mode: prerelease packages without a changeset graduate to stable.</summary>
17+
Exit,
18+
19+
/// <summary>A snapshot release: each module takes a throwaway <c>{base}-{suffix}</c> version.</summary>
20+
Snapshot,
21+
}
22+
23+
/// <summary>
24+
/// The fully-gathered inputs to a release plan. All I/O (reading changesets, projects, prestate, resolving
25+
/// commit facts and the snapshot suffix) happens before this is built, so <see cref="IReleasePlanner.Assemble"/>
26+
/// stays a pure transform.
27+
/// </summary>
28+
internal sealed record ReleaseInputs
29+
{
30+
/// <summary>The changesets to release.</summary>
31+
public required ChangesetFile[] Changesets { get; init; }
32+
33+
/// <summary>The discovered .NET projects, already adjusted for the chosen version strategy.</summary>
34+
public required IReadOnlyList<CsProject> Projects { get; init; }
35+
36+
/// <summary>The changeset configuration driving dependent bumps, grouping, and ignored packages.</summary>
37+
public required ChangesetConfig Config { get; init; }
38+
39+
/// <summary>The kind of release to assemble.</summary>
40+
public ReleaseMode Mode { get; init; } = ReleaseMode.Normal;
41+
42+
/// <summary>Per-changeset-id commit facts for the git/github changelog generators. Optional.</summary>
43+
public IReadOnlyDictionary<string, ChangesetCommitInfo>? CommitInfo { get; init; }
44+
45+
/// <summary>The prerelease tag (e.g. <c>next</c>), required when <see cref="Mode"/> is <see cref="ReleaseMode.Pre"/>.</summary>
46+
public string? PreTag { get; init; }
47+
48+
/// <summary>The resolved snapshot suffix, required when <see cref="Mode"/> is <see cref="ReleaseMode.Snapshot"/>.</summary>
49+
public string? SnapshotSuffix { get; init; }
50+
51+
/// <summary>Whether snapshot versions build on the calculated next version rather than <c>0.0.0</c>.</summary>
52+
public bool SnapshotUseCalculatedVersion { get; init; }
53+
}
54+
55+
/// <summary>
56+
/// Assembles a release plan from already-gathered inputs: runs the changelog generator (dependency cascade,
57+
/// linked/fixed/lockstep grouping) and folds in the version computation for the release <see cref="ReleaseMode"/>.
58+
/// Pure - both <c>version</c> and <c>status</c> call this so they can never disagree on what a release looks like,
59+
/// mirroring <c>@changesets</c>' shared <c>assembleReleasePlan</c>.
60+
/// </summary>
61+
internal interface IReleasePlanner
62+
{
63+
/// <summary>
64+
/// Computes the per-module changelogs for the release, with each module's final version resolved according to
65+
/// <see cref="ReleaseInputs.Mode"/>.
66+
/// </summary>
67+
List<ModuleChangelog> Assemble(ReleaseInputs inputs);
68+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using SolarWinds.Changesets.Shared;
2+
3+
namespace SolarWinds.Changesets.Commands.Version.Helpers;
4+
5+
/// <inheritdoc />
6+
internal sealed class ReleasePlanner : IReleasePlanner
7+
{
8+
private static readonly IReadOnlyDictionary<string, ChangesetCommitInfo> s_noCommitInfo =
9+
new Dictionary<string, ChangesetCommitInfo>(StringComparer.Ordinal);
10+
11+
private readonly IChangelogGenerator _changelogGenerator;
12+
13+
public ReleasePlanner(IChangelogGenerator changelogGenerator)
14+
{
15+
_changelogGenerator = changelogGenerator;
16+
}
17+
18+
/// <inheritdoc />
19+
public List<ModuleChangelog> Assemble(ReleaseInputs inputs)
20+
{
21+
List<ModuleChangelog> changelogs = _changelogGenerator
22+
.GetProcessedChangelogs(inputs.Changesets, inputs.Projects, inputs.Config, inputs.CommitInfo ?? s_noCommitInfo)
23+
.ToList();
24+
25+
switch (inputs.Mode)
26+
{
27+
case ReleaseMode.Snapshot:
28+
ApplySnapshotVersions(changelogs, inputs);
29+
break;
30+
31+
case ReleaseMode.Pre:
32+
ApplyPreVersions(changelogs, inputs.PreTag ?? string.Empty);
33+
break;
34+
35+
case ReleaseMode.Exit:
36+
AddPrereleaseGraduations(changelogs, inputs.Projects);
37+
break;
38+
39+
case ReleaseMode.Normal:
40+
default:
41+
break;
42+
}
43+
44+
return changelogs;
45+
}
46+
47+
private static void ApplySnapshotVersions(List<ModuleChangelog> changelogs, ReleaseInputs inputs)
48+
{
49+
string suffix = inputs.SnapshotSuffix ?? string.Empty;
50+
51+
foreach (ModuleChangelog changelog in changelogs)
52+
{
53+
changelog.NewVersionOverride = ReleaseVersionPlanner.SnapshotVersion(
54+
changelog, inputs.SnapshotUseCalculatedVersion, suffix);
55+
}
56+
}
57+
58+
private static void ApplyPreVersions(List<ModuleChangelog> changelogs, string tag)
59+
{
60+
foreach (ModuleChangelog changelog in changelogs)
61+
{
62+
changelog.NewVersionOverride = ReleaseVersionPlanner.PreVersion(changelog, tag);
63+
}
64+
}
65+
66+
private static void AddPrereleaseGraduations(List<ModuleChangelog> changelogs, IReadOnlyList<CsProject> csProjects)
67+
{
68+
// On exit, any package still on a prerelease version that has no changeset graduates to its stable version.
69+
HashSet<string> covered = changelogs.Select(changelog => changelog.ModuleName).ToHashSet(StringComparer.Ordinal);
70+
71+
foreach (CsProject csProject in csProjects)
72+
{
73+
if (csProject.Version.Prerelease is null || covered.Contains(csProject.Name))
74+
{
75+
continue;
76+
}
77+
78+
changelogs.Add(new ModuleChangelog
79+
{
80+
ModuleName = csProject.Name,
81+
ModuleCsProjFilePath = csProject.CsprojFilePath,
82+
CurrentVersion = csProject.Version,
83+
PackageTitle = csProject.PackageId,
84+
BumpTypeOverride = BumpType.Patch,
85+
Changes = [],
86+
});
87+
}
88+
}
89+
}

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

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal sealed class VersionChangesetCommand : ConfigurationCommandBase<Version
1515
private readonly IAnsiConsole _console;
1616
private readonly IChangesetsRepository _changesetsRepository;
1717
private readonly ICsProjectsRepository _csProjFileHelper;
18-
private readonly IChangelogGenerator _changelogGenerator;
18+
private readonly IReleasePlanner _releasePlanner;
1919
private readonly IChangelogFileWriter _changelogFileWriter;
2020
private readonly IChangelogFormatter _changelogFormatter;
2121
private readonly INodeChangesetService _nodeChangesetService;
@@ -33,7 +33,7 @@ public VersionChangesetCommand(
3333
IConfigurationService configurationService,
3434
IChangesetsRepository changesetsRepository,
3535
ICsProjectsRepository csProjFileHelper,
36-
IChangelogGenerator changelogGenerator,
36+
IReleasePlanner releasePlanner,
3737
IChangelogFileWriter changelogFileWriter,
3838
IChangelogFormatter changelogFormatter,
3939
INodeChangesetService nodeChangesetService,
@@ -46,7 +46,7 @@ IChangelogCommitResolver commitResolver
4646
_console = console;
4747
_changesetsRepository = changesetsRepository;
4848
_csProjFileHelper = csProjFileHelper;
49-
_changelogGenerator = changelogGenerator;
49+
_releasePlanner = releasePlanner;
5050
_changelogFileWriter = changelogFileWriter;
5151
_changelogFormatter = changelogFormatter;
5252
_nodeChangesetService = nodeChangesetService;
@@ -126,24 +126,22 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Vers
126126
ChangesetConfig.Changelog,
127127
Constants.WorkingDirectoryFullPath);
128128

129-
List<ModuleChangelog> changelogs = _changelogGenerator
130-
.GetProcessedChangelogs(relevantChangesets, csProjects, ChangesetConfig, commitInfo).ToList();
129+
ReleaseMode mode = snapshot ? ReleaseMode.Snapshot
130+
: inPreMode ? ReleaseMode.Pre
131+
: exiting ? ReleaseMode.Exit
132+
: ReleaseMode.Normal;
131133

132-
if (snapshot)
134+
List<ModuleChangelog> changelogs = _releasePlanner.Assemble(new ReleaseInputs
133135
{
134-
await ApplySnapshotVersionsAsync(changelogs, settings);
135-
}
136-
else if (inPreMode)
137-
{
138-
foreach (ModuleChangelog changelog in changelogs)
139-
{
140-
changelog.NewVersionOverride = ReleaseVersionPlanner.PreVersion(changelog, preState!.Tag);
141-
}
142-
}
143-
else if (exiting)
144-
{
145-
AddPrereleaseGraduations(changelogs, csProjects);
146-
}
136+
Changesets = relevantChangesets,
137+
Projects = csProjects,
138+
Config = ChangesetConfig,
139+
Mode = mode,
140+
CommitInfo = commitInfo,
141+
PreTag = inPreMode ? preState!.Tag : null,
142+
SnapshotSuffix = snapshot ? await BuildSnapshotSuffixAsync(settings) : null,
143+
SnapshotUseCalculatedVersion = ChangesetConfig.Snapshot.UseCalculatedVersion,
144+
});
147145

148146
if (changelogs.Count == 0)
149147
{
@@ -215,7 +213,9 @@ private static string BuildNodeVersionSubcommand(VersionChangesetCommandSettings
215213
: "version --snapshot";
216214
}
217215

218-
private async Task ApplySnapshotVersionsAsync(List<ModuleChangelog> changelogs, VersionChangesetCommandSettings settings)
216+
// Resolves the snapshot suffix - the one impure part of snapshot versioning (it may read the current commit
217+
// id and the clock). The planner applies the resolved suffix purely.
218+
private async Task<string> BuildSnapshotSuffixAsync(VersionChangesetCommandSettings settings)
219219
{
220220
string? template = settings.SnapshotPrereleaseTemplate ?? ChangesetConfig.Snapshot.PrereleaseTemplate;
221221
string? tag = settings.Snapshot.Value;
@@ -224,37 +224,7 @@ private async Task ApplySnapshotVersionsAsync(List<ModuleChangelog> changelogs,
224224
? await _gitService.GetCurrentCommitIdAsync(Constants.WorkingDirectoryFullPath)
225225
: null;
226226

227-
string suffix = SnapshotSuffix.Build(template, tag, commit, DateTimeOffset.UtcNow);
228-
229-
foreach (ModuleChangelog changelog in changelogs)
230-
{
231-
changelog.NewVersionOverride = ReleaseVersionPlanner.SnapshotVersion(
232-
changelog, ChangesetConfig.Snapshot.UseCalculatedVersion, suffix);
233-
}
234-
}
235-
236-
private static void AddPrereleaseGraduations(List<ModuleChangelog> changelogs, CsProject[] csProjects)
237-
{
238-
// On exit, any package still on a prerelease version that has no changeset graduates to its stable version.
239-
HashSet<string> covered = changelogs.Select(changelog => changelog.ModuleName).ToHashSet(StringComparer.Ordinal);
240-
241-
foreach (CsProject csProject in csProjects)
242-
{
243-
if (csProject.Version.Prerelease is null || covered.Contains(csProject.Name))
244-
{
245-
continue;
246-
}
247-
248-
changelogs.Add(new ModuleChangelog
249-
{
250-
ModuleName = csProject.Name,
251-
ModuleCsProjFilePath = csProject.CsprojFilePath,
252-
CurrentVersion = csProject.Version,
253-
PackageTitle = csProject.PackageId,
254-
BumpTypeOverride = BumpType.Patch,
255-
Changes = [],
256-
});
257-
}
227+
return SnapshotSuffix.Build(template, tag, commit, DateTimeOffset.UtcNow);
258228
}
259229

260230
private void WarnOnUnknownChangesetNames(ChangesetFile[] changesets, CsProject[] csProjects)

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<IReleasePlanner, ReleasePlanner>();
3536
serviceCollection.AddSingleton<IChangelogCommitResolver, ChangelogCommitResolver>();
3637
serviceCollection.AddSingleton<IChangelogFormatter, ChangelogFormatter>();
3738
serviceCollection.AddSingleton<ICsProjectsRepository, CsProjectsRepository>();

0 commit comments

Comments
 (0)