|
| 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 | +} |
0 commit comments