// This program is minimal effort and should be sent to remedial school using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Security; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml.Linq; using Newtonsoft.Json; using Octokit; using Octokit.GraphQL; using Tgstation.Server.Shared; using YamlDotNet.Serialization; namespace Tgstation.Server.ReleaseNotes { /// /// Contains the application entrypoint /// static class Program { const string OutputPath = "release_notes.md"; // some stuff that should be abstracted for different repos const string RepoOwner = "tgstation"; const string RepoName = "tgstation-server"; const int AppId = 847638; /// /// The entrypoint for the /// static async Task Main(string[] args) { if (args.Length < 1) { Console.WriteLine("Missing version argument!"); return 1; } var versionString = args[0]; var ensureRelease = versionString.Equals("--ensure-release", StringComparison.OrdinalIgnoreCase); var linkWinget = versionString.Equals("--link-winget", StringComparison.OrdinalIgnoreCase); var shaCheck = versionString.Equals("--winget-template-check", StringComparison.OrdinalIgnoreCase); var fullNotes = versionString.Equals("--generate-full-notes", StringComparison.OrdinalIgnoreCase); var nuget = versionString.Equals("--nuget", StringComparison.OrdinalIgnoreCase); if ((!Version.TryParse(versionString, out var version) || version.Revision != -1) && !ensureRelease && !linkWinget && !shaCheck && !fullNotes && !nuget) { Console.WriteLine("Invalid version: " + versionString); return 2; } var doNotCloseMilestone = false; var debianMode = false; Component? componentRelease = null; if (args.Length > 1) switch (args[1].ToUpperInvariant()) { case "--DEBIAN": debianMode = true; doNotCloseMilestone = true; if (args.Length < 3) { Console.WriteLine("Missing output path!"); return 238; } if (args.Length < 4) { Console.WriteLine("Missing current SHA!"); return 239; } break; case "--NO-CLOSE": doNotCloseMilestone = true; break; case "--RESTAPI": componentRelease = Component.HttpApi; break; case "--GRAPHQLAPI": componentRelease = Component.GraphQLApi; break; case "--INTEROPAPI": componentRelease = Component.InteropApi; break; case "--DMAPI": componentRelease = Component.DreamMakerApi; break; } var client = new GitHubClient(new Octokit.ProductHeaderValue("tgs_release_notes")); const string ReleaseNotesEnvVar = "TGS_RELEASE_NOTES_TOKEN"; var githubToken = Environment.GetEnvironmentVariable(ReleaseNotesEnvVar); if (String.IsNullOrWhiteSpace(githubToken) && !doNotCloseMilestone && !ensureRelease) { Console.WriteLine("Missing " + ReleaseNotesEnvVar + " environment variable!"); return 3; } if (!String.IsNullOrWhiteSpace(githubToken)) { client.Credentials = new Credentials(githubToken); } try { if (ensureRelease) return await EnsureRelease(client); if (linkWinget) { if (args.Length < 2 || !Uri.TryCreate(args[1], new UriCreationOptions(), out var actionsUrl)) { Console.WriteLine("Missing/Invalid actions URL!"); return 30; } return await Winget(client, actionsUrl, null); } if (shaCheck) { if (args.Length < 2) { Console.WriteLine("Missing SHA for PR template!"); return 32; } return await Winget(client, null, args[1]); } if (fullNotes) return await FullNotes(client); if (componentRelease.HasValue) return await ReleaseComponent(client, version, componentRelease.Value); if (nuget) return await ReleaseNuget(client); if (debianMode) return await GenDebianChangelog(client, version, args[2], args[3]); var releasesTask = client.Repository.Release.GetAll(RepoOwner, RepoName); Console.WriteLine("Getting merged pull requests in milestone " + versionString + "..."); var milestonePRs = await client.Search.SearchIssues(new SearchIssuesRequest { Milestone = $"v{versionString}", Type = IssueTypeQualifier.PullRequest, Repos = { { RepoOwner, RepoName } } }).ConfigureAwait(false); if (milestonePRs.IncompleteResults) { Console.WriteLine("Incomplete results for milestone PRs query!"); return 5; } Console.WriteLine(milestonePRs.Items.Count + " total pull requests"); bool postControlPanelMessage = false; var noteTasks = new List, Dictionary, bool>>>(); foreach (var I in milestonePRs.Items) noteTasks.Add(GetReleaseNotesFromPR(client, I, doNotCloseMilestone, false, false)); var releases = await releasesTask.ConfigureAwait(false); Version highestReleaseVersion = null; Release highestRelease = null; foreach (var I in releases) { if (!Version.TryParse(I.TagName.Replace("tgstation-server-v", String.Empty), out var currentReleaseVersion)) { Console.WriteLine("WARNING: Unable to determine version of release " + I.HtmlUrl); continue; } if (currentReleaseVersion.Major > 3 && (highestReleaseVersion == null || currentReleaseVersion > highestReleaseVersion) && version != currentReleaseVersion) { highestReleaseVersion = currentReleaseVersion; highestRelease = I; } } if (highestReleaseVersion == null) { Console.WriteLine("Unable to determine highest release version!"); return 6; } var oldNotes = highestRelease.Body; var splits = new List(oldNotes.Split('\n')); //trim away all the lines that don't start with # string keepThisRelease; if (version.Build <= 1) keepThisRelease = "# "; else keepThisRelease = "## "; for (; !splits[0].StartsWith(keepThisRelease, StringComparison.Ordinal); splits.RemoveAt(0)) if (splits.Count == 1) { Console.WriteLine("Error formatting release notes: Can't detemine notes start!"); return 7; } oldNotes = String.Join('\n', splits); string prefix; const string PropsPath = "build/Version.props"; const string ControlPanelPropsPath = "build/WebpanelVersion.props"; var doc = XDocument.Load(PropsPath); var project = doc.Root; var xmlNamespace = project.GetDefaultNamespace(); var versionsPropertyGroup = project.Elements().First(x => x.Name == xmlNamespace + "PropertyGroup"); var doc2 = XDocument.Load(ControlPanelPropsPath); var project2 = doc2.Root; var controlPanelXmlNamespace = project2.GetDefaultNamespace(); var controlPanelVersionsPropertyGroup = project2.Elements().First(x => x.Name == controlPanelXmlNamespace + "PropertyGroup"); var coreVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsCoreVersion").Value); if (coreVersion != version) { Console.WriteLine("Received a different version on command line than in Version.props!"); return 10; } var restVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsRestVersion").Value); var graphQLVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsGraphQLVersion").Value); var configVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsConfigVersion").Value); var dmApiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsDmapiVersion").Value); var interopVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsInteropVersion").Value); var webControlVersion = Version.Parse(controlPanelVersionsPropertyGroup.Element(controlPanelXmlNamespace + "TgsWebpanelVersion").Value); var hostWatchdogVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsHostWatchdogVersion").Value); if (webControlVersion.Major == 0) postControlPanelMessage = true; prefix = $"Please refer to the [README](https://github.com/tgstation/tgstation-server#setup) for setup instructions. Full changelog can be found [here](https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml).{Environment.NewLine}{Environment.NewLine}#### Component Versions\nCore: {coreVersion}\nConfiguration: {configVersion}\nREST API: {restVersion}\nGraphQL API{(graphQLVersion.Major < 1 ? " (Pre-release)" : String.Empty)}: {graphQLVersion}\nDreamMaker API: {dmApiVersion} (Interop: {interopVersion})\n[Web Control Panel](https://github.com/tgstation/tgstation-server-webpanel): {webControlVersion}\nHost Watchdog: {hostWatchdogVersion}"; var newNotes = new StringBuilder(prefix); if (postControlPanelMessage) { newNotes.Append(Environment.NewLine); newNotes.Append(Environment.NewLine); newNotes.Append("### The recommended client is currently the legacy [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel/releases/latest). This will be phased out as the web client is completed."); } newNotes.Append(Environment.NewLine); newNotes.Append(Environment.NewLine); if (version.Build == 0) { newNotes.Append("# [Update "); newNotes.Append(version.Minor); newNotes.Append(".X"); } else { newNotes.Append("## [Patch "); newNotes.Append(version.Build); } newNotes.Append("]("); await Task.WhenAll(noteTasks); var milestone = milestones.Single().Value; if (milestone == null) { Console.WriteLine("Unable to detemine milestone!"); return 9; } var allTasks = new List(noteTasks); if (doNotCloseMilestone) Console.WriteLine("Not closing milestone due to parameter!"); else { Console.WriteLine("Closing milestone..."); allTasks.Add(client.Issue.Milestone.Update(RepoOwner, RepoName, milestone.Number, new MilestoneUpdate { State = ItemState.Closed })); // Create the next patch milestone var nextPatchMilestoneName = $"v{version.Major}.{version.Minor}.{version.Build + 1}"; Console.WriteLine($"Creating milestone {nextPatchMilestoneName}..."); var nextPatchMilestone = await client.Issue.Milestone.Create( RepoOwner, RepoName, new NewMilestone(nextPatchMilestoneName) { Description = "Next patch version" }); async ValueTask RelocateOpenIssues(Milestone originalMilestone, int moveToMilestoneNumber) { if (originalMilestone.OpenIssues + originalMilestone.ClosedIssues > 0) { var issuesInUnusedMilestone = await client.Search.SearchIssues(new SearchIssuesRequest { Milestone = originalMilestone.Title, Repos = { { RepoOwner, RepoName } } }); var issueUpdateTasks = new List(); foreach (var I in issuesInUnusedMilestone.Items) { if (I.State.Value != ItemState.Closed) issueUpdateTasks.Add(client.Issue.Update(RepoOwner, RepoName, I.Number, new IssueUpdate { Milestone = moveToMilestoneNumber })); if (I.PullRequest != null && I.PullRequest.Merged) { Console.WriteLine($"Adding additional merged PR #{I.Number}..."); var task = GetReleaseNotesFromPR(client, I, doNotCloseMilestone, false, false); noteTasks.Add(task); allTasks.Add(task); } } await Task.WhenAll(issueUpdateTasks).ConfigureAwait(false); } } if (version.Build == 0) { // close the patch milestone if it exists var milestones = await client.Issue.Milestone.GetAllForRepository(RepoOwner, RepoName, new MilestoneRequest { State = ItemStateFilter.Open }); async ValueTask DeleteMilestone(Milestone milestoneToDelete, int moveToMilestoneNumber) { Console.WriteLine($"Moving {milestoneToDelete.OpenIssues} open issues and {milestoneToDelete.ClosedIssues} closed issues from unused patch milestone {milestoneToDelete.Title} to upcoming ones and deleting..."); await RelocateOpenIssues(milestoneToDelete, moveToMilestoneNumber); allTasks.Add(client.Issue.Milestone.Delete(RepoOwner, RepoName, milestoneToDelete.Number)); } var unreleasedNextPatchMilestone = milestones.FirstOrDefault(x => x.Title.StartsWith($"v{highestReleaseVersion.Major}.{highestReleaseVersion.Minor}.")); if (unreleasedNextPatchMilestone != null) await DeleteMilestone(unreleasedNextPatchMilestone, nextPatchMilestone.Number); // Create the next minor milestone var nextMinorMilestoneName = $"v{version.Major}.{version.Minor + 1}.0"; Console.WriteLine($"Creating milestone {nextMinorMilestoneName}..."); var nextMinorMilestoneTask = client.Issue.Milestone.Create( RepoOwner, RepoName, new NewMilestone(nextMinorMilestoneName) { Description = "Next minor version" }); allTasks.Add(nextMinorMilestoneTask); // Move unfinished stuff to new minor milestone Console.WriteLine($"Moving {milestone.OpenIssues} abandoned issue(s) from previous milestone to new one..."); var abandonedIssues = await client.Search.SearchIssues(new SearchIssuesRequest { Milestone = milestone.Title, Repos = { { RepoOwner, RepoName } }, State = ItemState.Open }); var nextMinorMilestone = await nextMinorMilestoneTask.ConfigureAwait(false); if (abandonedIssues.Items.Any()) { foreach (var I in abandonedIssues.Items) allTasks.Add(client.Issue.Update(RepoOwner, RepoName, I.Number, new IssueUpdate { Milestone = nextMinorMilestone.Number })); } if (version.Minor == 0 && version.Build == 0) { // major release var unreleasedNextMinorMilestone = milestones.FirstOrDefault(x => x.Title.StartsWith($"v{highestReleaseVersion.Major}.{highestReleaseVersion.Minor + 1}.0")); if (unreleasedNextMinorMilestone != null) await DeleteMilestone(unreleasedNextMinorMilestone, nextMinorMilestone.Number); } else await RelocateOpenIssues(milestone, nextMinorMilestone.Number); } else await RelocateOpenIssues(milestone, nextPatchMilestone.Number); } newNotes.Append(milestone.HtmlUrl); newNotes.Append("?closed=1)"); newNotes.Append(Environment.NewLine); await Task.WhenAll(allTasks).ConfigureAwait(false); var componentVersionDict = new Dictionary { { Component.Configuration, configVersion }, { Component.HttpApi, restVersion }, { Component.GraphQLApi, graphQLVersion }, { Component.DreamMakerApi, dmApiVersion }, { Component.InteropApi, interopVersion }, { Component.WebControlPanel, webControlVersion }, { Component.HostWatchdog, hostWatchdogVersion }, }; var releaseDictionary = new SortedDictionary( new Dictionary( noteTasks .Where(task => task.Result != null) .SelectMany(task => task.Result.Item1) .Where(kvp => kvp.Key == Component.Core || componentVersionDict.ContainsKey(kvp.Key)) .GroupBy(kvp => kvp.Key) .Select(grouping => { var component = grouping.Key; var changelist = new Changelist { Changes = grouping.SelectMany(kvp => kvp.Value.Changes).ToList() }; if (component == Component.Core) { changelist.Version = coreVersion; changelist.ComponentVersions = componentVersionDict; } else changelist.Version = componentVersionDict[component]; return new KeyValuePair(component, changelist); }))); if (releaseDictionary.Count == 0) { Console.WriteLine("No release notes for this milestone!"); return 8; } foreach (var I in releaseDictionary) { newNotes.Append(Environment.NewLine); newNotes.Append("#### "); string componentName = GetComponentDisplayName(I.Key, false); newNotes.Append(componentName); if (I.Key == Component.Configuration) { I.Value.StripConfigVersionMessage(); newNotes.AppendLine(); newNotes.Append("- **The new configuration version is `"); newNotes.Append(I.Value.Version); newNotes.Append("`. Please update your `General:ConfigVersion` setting appropriately.**"); } PrintChanges(newNotes, I.Value); newNotes.Append(Environment.NewLine); } newNotes.Append(Environment.NewLine); if (version.Minor != 0 && version.Build != 0) newNotes.Append(oldNotes); Console.WriteLine($"Writing out new release notes to {Path.GetFullPath(OutputPath)}..."); var releaseNotes = newNotes.ToString(); await File.WriteAllTextAsync(OutputPath, releaseNotes).ConfigureAwait(false); Console.WriteLine("Updating Server Release Thread..."); var productInformation = new Octokit.GraphQL.ProductHeaderValue("tgs_release_notes"); var connection = new Octokit.GraphQL.Connection(productInformation, githubToken); var mutation = new Mutation() .AddDiscussionComment(new Octokit.GraphQL.Model.AddDiscussionCommentInput { Body = $"[tgstation-server-v{versionString}](https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v{versionString}) released.", DiscussionId = new ID("MDEwOkRpc2N1c3Npb24zNTU5OTUx") }) .Select(payload => new { payload.ClientMutationId }) .Compile(); if (!doNotCloseMilestone) await connection.Run(mutation); return 0; } catch (Exception e) { Console.WriteLine(e); return 4; } } static string GetComponentDisplayName(Component component, bool debian) => component switch { Component.HttpApi => debian ? "the REST API" : "REST API", Component.GraphQLApi => debian ? "the GraphQL API" : "GraphQL API", Component.InteropApi => debian ? "the Interop API" : "Interop API", Component.Configuration => debian ? "the TGS configuration" : "**Configuration**", Component.DreamMakerApi => debian ? "the DreamMaker API" : "DreamMaker API", Component.HostWatchdog => debian ? "the Host Watchdog" : "Host Watchdog", Component.Core => debian ? "the main server" : "Core", Component.WebControlPanel => debian ? "the Web Control Panel" : "Web Control Panel", _ => throw new Exception($"Unnamed Component: {component}"), }; static readonly ConcurrentDictionary milestones = new(); static readonly ConcurrentDictionary> pullRequests = new(); static Task GetPR(IGitHubClient client, int pr) => pullRequests.GetOrAdd(pr, x => RLR(() => client.Repository.PullRequest.Get(RepoOwner, RepoName, x))); static async Task, Dictionary, bool>> GetReleaseNotesFromPR(IGitHubClient client, Issue pullRequest, bool doNotCloseMilestone, bool needComponentExactVersions, bool forAllComponents) { //need to check it was merged var prTask = GetPR(client, pullRequest.Number); var fullPR = await prTask; if (!fullPR.Merged) { if (!doNotCloseMilestone && fullPR.Milestone != null) { Console.WriteLine($"Removing trash PR #{fullPR.Number} from milestone..."); await RLR(() => client.Issue.Update(RepoOwner, RepoName, fullPR.Number, new IssueUpdate { Milestone = null })); } return null; } if (fullPR.Milestone == null) { return null; } milestones.TryAdd(fullPR.Milestone.Number, fullPR.Milestone); var commentsTask = TripleCheckGitHubPagination(apiOptions => client.Issue.Comment.GetAllForIssue(fullPR.Base.Repository.Id, pullRequest.Number, apiOptions), comment => comment.Id); bool isReleasePR = false; async Task ShouldGetExtendedComponentVersions() { if (forAllComponents) return true; var commit = await RLR(() => client.Repository.Commit.Get(fullPR.Base.Repository.Id, fullPR.MergeCommitSha)); isReleasePR = commit.Commit.Message.Contains("[TGSDeploy]") || fullPR.Number == 966 || fullPR.Number == 1048 || fullPR.Number == 1435 || fullPR.Number == 1263 || fullPR.Number == 1087 || fullPR.Number == 1441 || fullPR.Number == 1437 || fullPR.Number == 1443 || fullPR.Number == 1311 || fullPR.Number == 1598 || fullPR.Number == 1463 || fullPR.Number == 1209; // some special tactics from before we were more stingent return isReleasePR; } Task needExtendedComponentVersions = Task.FromResult(false); async Task> GetComponentVersions() { var mergeCommit = fullPR.MergeCommitSha; // we don't care about unreleased web control panel changes needExtendedComponentVersions = ShouldGetExtendedComponentVersions(); var versionsBytes = await RLR(() => client.Repository.Content.GetRawContentByRef(RepoOwner, RepoName, "build/Version.props", mergeCommit)); XDocument doc; using (var ms = new MemoryStream(versionsBytes)) doc = XDocument.Load(ms); var project = doc.Root; var xmlNamespace = project.GetDefaultNamespace(); var versionsPropertyGroup = project.Elements().First(x => x.Name == xmlNamespace + "PropertyGroup"); Version Parse(string elemName, bool controlPanel = false) { var element = versionsPropertyGroup.Element(xmlNamespace + elemName); if (element == null) return null; return Version.Parse(element.Value); } var dict = new Dictionary { { Component.Core, Parse("TgsCoreVersion") }, { Component.HttpApi, Parse("TgsRestVersion") }, { Component.GraphQLApi, Parse("TgsGraphQLVersion") }, { Component.DreamMakerApi, Parse("TgsDmapiVersion") }, }; if (await needExtendedComponentVersions) { // only grab some versions at release time // we aggregate later dict.Add(Component.Configuration, Parse("TgsConfigVersion")); dict.Add(Component.InteropApi, Parse("TgsInteropVersion")); dict.Add(Component.HostWatchdog, Parse("TgsHostWatchdogVersion")); dict.Add(Component.NugetCommon, Parse("TgsCommonLibraryVersion")); dict.Add(Component.NugetApi, Parse("TgsApiLibraryVersion")); dict.Add(Component.NugetClient, Parse("TgsClientVersion")); var webVersion = Parse("TgsControlPanelVersion"); if (webVersion != null) { dict.Add(Component.WebControlPanel, webVersion); } else { byte[] controlPanelVersionBytes; string elementName; try { controlPanelVersionBytes = await RLR(() => client.Repository.Content.GetRawContentByRef(RepoOwner, RepoName, "build/WebpanelVersion.props", mergeCommit)); elementName = "TgsWebpanelVersion"; } catch (NotFoundException) { controlPanelVersionBytes = await RLR(() => client.Repository.Content.GetRawContentByRef(RepoOwner, RepoName, "build/ControlPanelVersion.props", mergeCommit)); elementName = "TgsControlPanelVersion"; } using (var ms = new MemoryStream(controlPanelVersionBytes)) doc = XDocument.Load(ms); project = doc.Root; var controlPanelXmlNamespace = project.GetDefaultNamespace(); var controlPanelVersionsPropertyGroup = project.Elements().First(x => x.Name == controlPanelXmlNamespace + "PropertyGroup"); dict.Add(Component.WebControlPanel, Version.Parse(controlPanelVersionsPropertyGroup.Element(controlPanelXmlNamespace + elementName).Value)); } } return dict; } var componentVersions = needComponentExactVersions ? GetComponentVersions() : Task.FromResult>(null); var changelists = new ConcurrentDictionary(); async Task BuildNotesFromComment(string comment, User user, Task localPreviousTask) { await localPreviousTask; if (comment == null) return; async Task CommitNotes(Component component, List notes) { foreach (var I in notes) Console.WriteLine(component + " #" + fullPR.Number + " - " + I + " (@" + user.Login + ")"); var tupleSelector = notes.Select(note => new Change { Descriptions = new List { note }, PullRequest = fullPR.Number, Author = user.Login }); var useExtendedComponentVersions = await needExtendedComponentVersions; var componentVersionsResult = await componentVersions; lock (changelists) if (changelists.TryGetValue(component, out var currentChangelist)) currentChangelist.Changes.AddRange(tupleSelector); else DebugAssert(changelists.TryAdd(component, new Changelist { Changes = tupleSelector.ToList(), Unreleased = false, Version = needComponentExactVersions && componentVersionsResult.TryGetValue(component, out var componentVersion) ? componentVersion : null, ComponentVersions = component == Component.Core && needComponentExactVersions && useExtendedComponentVersions ? new Dictionary(componentVersionsResult.Where(kvp => kvp.Key != Component.Core)) : null })); } var commentSplits = comment.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); string targetComponent = null; var notes = new List(); foreach (var line in commentSplits) { var trimmedLine = line.Trim(); if (targetComponent == null) { if (trimmedLine.StartsWith(":cl:", StringComparison.Ordinal) || trimmedLine.StartsWith("🆑", StringComparison.Ordinal)) { var matchLength = trimmedLine.StartsWith("🆑", StringComparison.Ordinal) ? "🆑".Length : 4; targetComponent = trimmedLine[matchLength..].Trim(); if (targetComponent.Length == 0) targetComponent = "Core"; } continue; } if (trimmedLine.StartsWith("/:cl:", StringComparison.Ordinal) || trimmedLine.StartsWith("/🆑", StringComparison.Ordinal)) { if (!Enum.TryParse(targetComponent, out var component)) component = targetComponent.ToUpperInvariant() switch { "**CONFIGURATION**" or "CONFIGURATION" or "CONFIG" => Component.Configuration, "HTTP API" or "REST API" => Component.HttpApi, "GQL API" or "GRAPHQL API" or "GQL" or "GRAPHQL" => Component.GraphQLApi, "WEB CONTROL PANEL" => Component.WebControlPanel, "DMAPI" or "DREAMMAKER API" => Component.DreamMakerApi, "INTEROP API" => Component.InteropApi, "HOST WATCHDOG" => Component.HostWatchdog, "NUGET: API" => Component.NugetApi, "NUGET: COMMON" => Component.NugetCommon, "NUGET: CLIENT" => Component.NugetClient, _ => throw new Exception($"Unknown component: \"{targetComponent}\""), }; await CommitNotes(component, notes); targetComponent = null; notes.Clear(); continue; } if (trimmedLine.Length == 0) continue; notes.Add(trimmedLine); } } var previousTask = BuildNotesFromComment(fullPR.Body, fullPR.User, Task.CompletedTask); var comments = await commentsTask; foreach (var x in comments) previousTask = BuildNotesFromComment(x.Body, x.User, previousTask); await previousTask; DebugAssert(!(await needExtendedComponentVersions) || changelists.Where(x => x.Key == Component.Core).All(x => x.Value.ComponentVersions != null && x.Value.ComponentVersions.Count > 3)); return Tuple.Create(changelists.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), await componentVersions, isReleasePR); } static async Task EnsureRelease(IGitHubClient client) { Console.WriteLine("Ensuring latest release is a GitHub release..."); var latestRelease = await client.Repository.Release.GetLatest(RepoOwner, RepoName); const string TagPrefix = "tgstation-server-v"; static bool IsServerRelease(Release release) => release.TagName.StartsWith(TagPrefix); if (!IsServerRelease(latestRelease)) { var allReleases = await client.Repository.Release.GetAll(RepoOwner, RepoName); var orderedReleases = allReleases .Where(IsServerRelease) .OrderByDescending(x => Version.Parse(x.TagName[TagPrefix.Length..])); latestRelease = orderedReleases .First(); // this should set it as latest await client.Repository.Release.Edit(RepoOwner, RepoName, latestRelease.Id, new ReleaseUpdate { MakeLatest = MakeLatestQualifier.True }); } return 0; } static async Task Winget(IGitHubClient client, Uri actionUrl, string expectedTemplateSha) { const string PropsPath = "build/Version.props"; var doc = XDocument.Load(PropsPath); var project = doc.Root; var xmlNamespace = project.GetDefaultNamespace(); var versionsPropertyGroup = project.Elements().First(x => x.Name == xmlNamespace + "PropertyGroup"); var coreVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsCoreVersion").Value); const string BodyForPRSha = "b5780571c1a436c5f687bbd7cba753b3b503762b"; var prBody = $@"# Automated Pull Request This pull request was generated by our [deployment pipeline]({actionUrl}) as a result of the release of [tgstation-server-v{coreVersion}](https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v{coreVersion}). Validation was performed as part of the process. The user account that created this pull request is available to correct any issues. Checklist for Pull Requests - [x] Have you signed the [Contributor License Agreement](https://cla.opensource.microsoft.com/microsoft/winget-pkgs)? - [x] Is there a linked Issue? **No** Manifests - [x] Have you checked that there aren't other open [pull requests](https://github.com/microsoft/winget-pkgs/pulls) for the same manifest update/change? **Impossible** - [x] This PR only modifies one (1) manifest - [x] Have you [validated](https://github.com/microsoft/winget-pkgs/blob/master/doc/Authoring.md#validation) your manifest locally with `winget validate --manifest `? - [x] Have you tested your manifest locally with `winget install --manifest `? - [x] Does your manifest conform to the [1.10 schema](https://github.com/microsoft/winget-pkgs/tree/master/doc/manifest/schema/1.10.0)? Note: `` is the directory's name containing the manifest you're submitting. ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/microsoft/winget-pkgs/pull/$PR_NUMBER_SUBST$) --- "; if (expectedTemplateSha != null) { if (expectedTemplateSha != BodyForPRSha) { Console.WriteLine("winget-pkgs pull request template has updated. This tool will need to be updated to match!"); Console.WriteLine($"Expected {BodyForPRSha} found {expectedTemplateSha}"); return 33; } return 0; } var clientUser = await client.User.Current(); var userPrsOnWingetRepo = await client.Search.SearchIssues(new SearchIssuesRequest { Author = clientUser.Login, Is = new List { IssueIsQualifier.PullRequest }, State = ItemState.Open, Repos = new RepositoryCollection { { "microsoft", "winget-pkgs" }, }, }); var prToModify = userPrsOnWingetRepo.Items.OrderByDescending(pr => pr.Number).FirstOrDefault(); if (prToModify == null) { Console.WriteLine("Could not find open winget-pkgs PR!"); return 31; } await client.Issue.Update("microsoft", "winget-pkgs", prToModify.Number, new IssueUpdate { Body = prBody.Replace("$PR_NUMBER_SUBST$", prToModify.Number.ToString()), }); return 0; } static async Task RLR(Func> func) { while (true) try { return await func(); } catch (HttpRequestException ex) when (ex.InnerException is IOException ioEx && ioEx.InnerException is SocketException sockEx && sockEx.ErrorCode == 10053) { await Task.Delay(15000); } catch (SecondaryRateLimitExceededException) { await Task.Delay(15000); } catch (RateLimitExceededException ex) { var now = DateTimeOffset.UtcNow.AddSeconds(-10); if (ex.Reset > now) { var delay = ex.Reset - now; await Task.Delay(delay); } } } static async Task> TripleCheckGitHubPagination(Func>> apiCall, Func idSelector) { // I've seen GitHub pagination return incomplete result sets in the past // It has an in-built pagination limit of 100 var apiOptions = new ApiOptions { PageSize = 100 }; var results = await RLR(() => apiCall(apiOptions)); var distinctEntries = new Dictionary(results.Count); foreach (var result in results) distinctEntries.TryAdd(idSelector(result).ToString(), result); if (results.Count > 100) { results = await RLR(() => apiCall(apiOptions)); foreach (var result in results) distinctEntries.TryAdd(idSelector(result).ToString(), result); results = await RLR(() => apiCall(apiOptions)); foreach (var result in results) distinctEntries.TryAdd(idSelector(result).ToString(), result); } return distinctEntries.Values.ToList(); } static async Task> ProcessMilestone(IGitHubClient client, Milestone milestone) { // have to trust this works SearchIssuesResult results; var milestoneTask = Task.FromResult(milestone); var pullRequests = new Dictionary(); var iteration = 0; while (true) { results = await RLR(() => client.Search.SearchIssues(new SearchIssuesRequest { Type = IssueTypeQualifier.PullRequest, Milestone = milestone.Title, Repos = new RepositoryCollection { { RepoOwner, RepoName }, }, Merged = DateRange.GreaterThan(new DateTimeOffset(2018, 9, 27, 0, 0, 0, TimeSpan.Zero)), })); foreach (var result in results.Items) pullRequests.TryAdd(result.Number, result); if (results.IncompleteResults) continue; if (results.TotalCount <= 100 || ++iteration == 3) break; } async Task RunPRs() { var milestoneVersion = Version.Parse(milestone.Title[1..]); var prTasks = pullRequests.Select( kvp => GetReleaseNotesFromPR(client, kvp.Value, true, true, milestone.State.Value == ItemState.Open)) .ToList(); await Task.WhenAll(prTasks); var prResults = prTasks.Select(x => x.Result).ToList(); var releasePRResult = prResults.FirstOrDefault(x => x.Item3); prResults = prResults.Where(result => result != null).ToList(); Dictionary releasedComponentVersions; if (releasePRResult != null) releasedComponentVersions = releasePRResult.Item2; else { releasedComponentVersions = new Dictionary( prResults .SelectMany(result => result.Item2) .GroupBy(kvp => kvp.Key) .Select(grouping => new KeyValuePair(grouping.Key, grouping.Max(kvp => kvp.Value)))); foreach (var maxVersionKvp in prResults.SelectMany(x => x.Item1) .Where(x => !releasedComponentVersions.ContainsKey(x.Key)) .GroupBy(x => x.Key) .Select(group => { var versions = group .Where(x => x.Value.Version != null) .ToList(); if (versions.Count == 0) return new KeyValuePair(group.Key, null); return new KeyValuePair(group.Key, versions.Max(x => x.Value.Version)); }) .Where(kvp => kvp.Value != null) .ToList()) { releasedComponentVersions.Add(maxVersionKvp.Key, maxVersionKvp.Value); } } var finalResults = new Dictionary>(); foreach (var componentKvp in releasedComponentVersions) { var component = componentKvp.Key; var list = new List(); foreach (var changelistDict in prResults.Select(x => x.Item1)) { if (!changelistDict.TryGetValue(component, out var changelist)) continue; Version componentVersion = milestoneVersion; var unreleased = milestone.State.Value == ItemState.Open; if (component != Component.Core) { componentVersion = changelist.Version ?? componentKvp.Value; if (releasedNonCoreVersions != null && releasedNonCoreVersions.TryGetValue(component, out var releasedVersions) && !releasedVersions.Any(x => x == componentVersion)) { // roll forward var newList = releasedVersions .ToList(); newList.Add(componentVersion); newList = newList.OrderBy(x => x).ToList(); var index = newList.IndexOf(componentVersion); DebugAssert(index != -1); if (index != (newList.Count - 1)) { componentVersion = newList[index + 1]; unreleased = false; } else unreleased = true; } } var entry = list.FirstOrDefault(x => x.Version == componentVersion); if (entry == null) { entry = changelist; entry.Version = componentVersion; entry.Unreleased = unreleased; if (component == Component.Core && entry.ComponentVersions == null) entry.ComponentVersions = releasedComponentVersions; list.Add(entry); } else entry.Changes.AddRange(changelist.Changes); } DebugAssert(list.Select(x => x.Version.ToString()).Distinct().Count() == list.Count); if (component == Component.Core) { DebugAssert(list.All(x => x.Version == milestoneVersion)); } list = list.OrderByDescending(x => x.Version).ToList(); finalResults.Add(component, list); } if (!finalResults.ContainsKey(Component.Core) || finalResults[Component.Core].Count == 0) { finalResults.Remove(Component.Core); finalResults.Add(Component.Core, new List { new() { Changes = new List(), ComponentVersions = releasedComponentVersions, Unreleased = milestone.State.Value == ItemState.Open, Version = milestoneVersion, } }); } else DebugAssert(finalResults[Component.Core].All(x => x.Version == milestoneVersion && x.ComponentVersions != null && x.ComponentVersions.Count > 3)); var notes = new ReleaseNotes { Components = new SortedDictionary>(finalResults), }; return notes; } return RunPRs(); } static async Task FullNotes(IGitHubClient client) { var rateLimitInfo = client.GetLastApiInfo()?.RateLimit ?? (await client.RateLimit.GetRateLimits()).Rate; var startRateLimit = rateLimitInfo.Remaining; var releaseNotes = await GenerateNotes(client); Console.WriteLine($"Generating all release notes took {startRateLimit - client.GetLastApiInfo().RateLimit.Remaining} requests."); var serializer = new SerializerBuilder() .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults) .WithTypeConverter(new VersionConverter()) .Build(); var serializedYaml = serializer.Serialize(releaseNotes); await File.WriteAllTextAsync("changelog.yml", serializedYaml).ConfigureAwait(false); return 0; } static readonly HttpClient httpClient = new( new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }); static async Task> EnumerateNugetVersions(string package) { var url = new Uri($"https://api.nuget.org/v3/registration5-gz-semver2/{package.ToLowerInvariant()}/index.json"); using var req = new HttpRequestMessage(); req.Headers.UserAgent.Add(new System.Net.Http.Headers.ProductInfoHeaderValue("Tgstation.Server.ReleaseNotes", "0.1.0")); req.Method = HttpMethod.Get; req.RequestUri = url; using var resp = await httpClient.SendAsync(req); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(); dynamic dynamicJson = JsonConvert.DeserializeObject(json); var versions = (IEnumerable)dynamicJson.items[0].items; var results = versions .Select(x => Version.TryParse((string)x.catalogEntry.version, out var version) ? version : null) .Where(version => version != null) .OrderBy(x => x) .ToHashSet(); return results; } static IReadOnlyDictionary> releasedNonCoreVersions; static async Task GenerateNotes(IGitHubClient client, Dictionary forceReleaseVersions = null) { ReleaseNotes previousNotes = null; if (File.Exists("changelog.yml")) { var existingYml = await File.ReadAllTextAsync("changelog.yml"); var deserializer = new DeserializerBuilder() .Build(); previousNotes = deserializer.Deserialize(existingYml); } var releasesTask = TripleCheckGitHubPagination( apiOptions => client.Repository.Release.GetAll(RepoOwner, RepoName, apiOptions), release => release.Id); var milestones = await TripleCheckGitHubPagination( apiOptions => client.Issue.Milestone.GetAllForRepository(RepoOwner, RepoName, new MilestoneRequest { State = ItemStateFilter.All }, apiOptions), milestone => milestone.Id); var versionMilestones = milestones .Where(milestone => Regex.IsMatch(milestone.Title, @"v[1-9][0-9]*\.[1-9]*[0-9]+\.[1-9]*[0-9]+$")) .ToList(); var releases = await releasesTask; var nugetCommonVersions = EnumerateNugetVersions("Tgstation.Server.Common"); var nugetApiVersions = EnumerateNugetVersions("Tgstation.Server.Api"); var nugetClientVersions = EnumerateNugetVersions("Tgstation.Server.Client"); const string ApiTagPrefix = "api-v"; const string GraphQLTagPrefix = "graphql-v"; const string DMApiTagPrefix = "dmapi-v"; var newDic = new Dictionary> { { Component.HttpApi, releases .Where(x => x.TagName.StartsWith(ApiTagPrefix)) .Select(x => Version.Parse(x.TagName[ApiTagPrefix.Length..])) .OrderBy(x => x) .ToHashSet() }, { Component.GraphQLApi, releases .Where(x => x.TagName.StartsWith(GraphQLTagPrefix)) .Select(x => Version.Parse(x.TagName[GraphQLTagPrefix.Length..])) .OrderBy(x => x) .ToHashSet() }, { Component.DreamMakerApi, releases .Where(x => x.TagName.StartsWith(DMApiTagPrefix)) .Select(x => Version.Parse(x.TagName[DMApiTagPrefix.Length..])) .OrderBy(x => x) .ToHashSet() }, { Component.NugetCommon, await nugetCommonVersions }, { Component.NugetApi, await nugetApiVersions }, { Component.NugetClient, await nugetClientVersions } }; if (forceReleaseVersions != null) foreach (var kvp in forceReleaseVersions) if (!newDic[kvp.Key].Any(x => x == kvp.Value)) newDic[kvp.Key] = newDic[kvp.Key] .Concat(new List { kvp.Value }) .OrderBy(x => x) .ToHashSet(); releasedNonCoreVersions = newDic; var milestonesToProcess = versionMilestones; if (previousNotes != null) { var releasedVersions = previousNotes.Components[Component.Core].Where(cl => !cl.Unreleased).ToList(); milestonesToProcess = milestonesToProcess .Where(x => !releasedVersions.Any( version => version.Version == Version.Parse(x.Title.AsSpan(1)))) .ToList(); foreach (var kvp in previousNotes.Components) if (releasedNonCoreVersions.TryGetValue(kvp.Key, out var releasedComponentVersions)) kvp.Value.RemoveAll(x => x.Unreleased = !releasedComponentVersions.Any(y => y == x.Version)); else kvp.Value.RemoveAll(x => x.Unreleased); } var milestonePRTasks = milestonesToProcess .Select(milestone => ProcessMilestone(client, milestone)) .ToList(); await Task.WhenAll(milestonePRTasks); await Task.WhenAll(milestonePRTasks.Select(task => task.Result)); var coreCls = milestonePRTasks .SelectMany(task => task.Result.Result.Components) .Where(x => x.Key == Component.Core) .ToList(); DebugAssert( coreCls.Count == milestonesToProcess.Count); var distinctCoreVersions = coreCls .SelectMany(x => x.Value) .Select(x => x.Version.ToString()) .Distinct() .Select(Version.Parse) .OrderBy(x => x) .ToList(); var missingCoreVersions = milestonesToProcess .Where(x => !distinctCoreVersions.Any(y => Version.Parse(x.Title.AsSpan(1)) == y)) .ToList(); DebugAssert(missingCoreVersions.Count == 0); var changelistsGroupedByComponent = milestonePRTasks .SelectMany(task => task.Result.Result.Components) .GroupBy(kvp => kvp.Key) .ToDictionary(grouping => grouping.Key, grouping => grouping.SelectMany(kvp => kvp.Value)); var releaseNotes = new ReleaseNotes { Components = new SortedDictionary>( changelistsGroupedByComponent .ToDictionary( kvp => kvp.Key, kvp => kvp .Value .GroupBy(changelist => changelist.Version) .Select(grouping => { var firstEntry = grouping.First(); return new Changelist { Changes = grouping.SelectMany(cl => cl.Changes).ToList(), ComponentVersions = firstEntry.ComponentVersions, Unreleased = firstEntry.Unreleased, Version = grouping.Key }; }) .OrderByDescending(cl => cl.Version) .ToList())) }; DebugAssert(releaseNotes.Components.ContainsKey(Component.Core) && releaseNotes.Components[Component.Core].Count == milestonesToProcess.Count); if (previousNotes != null) { foreach (var component in Enum.GetValues()) { if (!previousNotes.Components.ContainsKey(component)) continue; if (releaseNotes.Components.TryGetValue(component, out var newChangelists)) { var missingVersions = previousNotes.Components[component] .Where(olderVersion => { var newerVersion = newChangelists.SingleOrDefault(y => olderVersion.Version == y.Version); if (newerVersion != null) { newerVersion.Changes.AddRange( olderVersion.Changes.Where(x => !newerVersion.Changes.Any(y => x.PullRequest == y.PullRequest))); return false; } return true; }); releaseNotes.Components[component] = newChangelists .Concat(missingVersions) .OrderByDescending(cl => cl.Version) .ToList(); } else releaseNotes.Components[component] = previousNotes.Components[component]; } } foreach (var kvp in releaseNotes.Components) { var distinctCount = kvp.Value.Select(changelist => changelist.Version.ToString()).Distinct().Count(); DebugAssert(distinctCount == kvp.Value.Count); foreach (var cl in kvp.Value) { cl.DeduplicateChanges(); if (kvp.Key == Component.Configuration) cl.StripConfigVersionMessage(); } } return releaseNotes; } static void PrintChanges(StringBuilder newNotes, Changelist changelist, bool debianMode = false) { var none = true; foreach (var change in changelist.Changes) foreach (var line in change.Descriptions) { none = false; newNotes.AppendLine(); if (debianMode) newNotes.Append(" * "); else newNotes.Append("- "); newNotes.Append(line); newNotes.Append(" (#"); newNotes.Append(change.PullRequest); newNotes.Append(" @"); newNotes.Append(change.Author); newNotes.Append(')'); } if (debianMode && none) throw new Exception($"Changlist {changelist.Version} has no changes!"); } static string GenerateComponentNotes(ReleaseNotes releaseNotes, Component component, Version version, bool useMarkdown) { var relevantChangelog = releaseNotes.Components[component].FirstOrDefault(x => x.Version == version); var newNotes = new StringBuilder( useMarkdown ? "Full changelog can be found [here](https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml)." : "Full changelog can be found here: https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml."); if (relevantChangelog != null) { newNotes.AppendLine(); PrintChanges(newNotes, relevantChangelog); } if (component == Component.DreamMakerApi) { newNotes.AppendLine(); newNotes.AppendLine("#tgs-dmapi-release"); } var markdown = newNotes.ToString(); return markdown; } static async Task ReleaseComponent(IGitHubClient client, Version version, Component component) { var releaseNotes = await GenerateNotes(client, new Dictionary { { component, version } }); await File.WriteAllTextAsync(OutputPath, GenerateComponentNotes(releaseNotes, component, version, true)); return 0; } // must run from repo root static async Task ReleaseNuget(IGitHubClient client) { const string PropsPath = "build/Version.props"; var doc = XDocument.Load(PropsPath); var project = doc.Root; var xmlNamespace = project.GetDefaultNamespace(); var versionsPropertyGroup = project.Elements().First(x => x.Name == xmlNamespace + "PropertyGroup"); var commonVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsCommonLibraryVersion").Value); var apiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsApiLibraryVersion").Value); var clientVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsClientVersion").Value); var componentVersions = new Dictionary { { Component.NugetCommon, commonVersion }, { Component.NugetApi, apiVersion }, { Component.NugetClient, clientVersion }, }; var releaseNotes = await GenerateNotes( client, componentVersions); const string CsprojSubstitution = "src/Tgstation.Server.$PROJECT$/Tgstation.Server.$PROJECT$.csproj"; var csprojNameMap = new Dictionary { { Component.NugetCommon, "Common" }, { Component.NugetApi, "Api" }, { Component.NugetClient, "Client" }, }; foreach (var kvp in csprojNameMap) { var component = kvp.Key; var csprojPath = CsprojSubstitution.Replace("$PROJECT$", kvp.Value); var markdown = GenerateComponentNotes(releaseNotes, component, componentVersions[component], false); var escapedMarkdown = SecurityElement.Escape(markdown); var originalCsproj = await File.ReadAllTextAsync(csprojPath); var substitutedCsproj = originalCsproj.Replace($"$(TGS_NUGET_RELEASE_NOTES_{kvp.Value.ToUpperInvariant()})", escapedMarkdown); await File.WriteAllTextAsync(csprojPath, substitutedCsproj); } return 0; } static async Task GenDebianChangelog(IGitHubClient client, Version version, string outputPath, string currentSha) { var tagsTask = RLR(() => TripleCheckGitHubPagination( apiOptions => client.Repository.GetAllTags(RepoOwner, RepoName, apiOptions), x => x.Name)); var currentRefTask = client.Repository.Commit.Get(RepoOwner, RepoName, currentSha); var releaseNotes = await GenerateNotes(client); // https://www.debian.org/doc/manuals/maint-guide/dreq.en.html#changelog // https://www.debian.org/doc/debian-policy/ch-source.html#s-dpkgchangelog /* package (version) distribution(s); urgency=urgency [optional blank line(s), stripped] * change details more change details [blank line(s), included in output of dpkg-parsechangelog] * even more change details [optional blank line(s), stripped] -- maintainer name [two spaces] date */ // debian package did not exist before uhhh... // var debianPackageFirstRelease = new Version(5, 13, 0); // can't use that, there are irreconcilable changelog/version errors // keep it straight going forwards var noChangelogsBeforeVersion = new Version(5, 14, 0); var coreChangelists = releaseNotes .Components[Component.Core] .Where(x => x.Version >= noChangelogsBeforeVersion && (!x.Unreleased || x.Version == version)) .OrderByDescending(x => x.Version) .ToList(); var currentReleaseChangelists = new List>(); for (var i = 0; i < coreChangelists.Count; ++i) { var currentDic = new SortedDictionary(); currentReleaseChangelists.Add(currentDic); var nowRelease = coreChangelists[i]; var previousRelease = (i + 1) < coreChangelists.Count ? coreChangelists[i + 1] : releaseNotes .Components[Component.Core] .First(x => x.Version == new Version(5, 13, 7)); currentDic.Add(Component.Core, nowRelease); foreach (var componentKvp in nowRelease.ComponentVersions) { try { var component = componentKvp.Key; if (component == Component.Core || component == Component.NugetClient || component == Component.NugetApi || component == Component.NugetCommon) continue; var hasPreviousRelease = previousRelease.ComponentVersions.TryGetValue(componentKvp.Key, out var takeNotesFrom); var changesEnumerator = releaseNotes .Components[component] .Where(changelist => !hasPreviousRelease || (changelist.Version > takeNotesFrom && changelist.Version <= componentKvp.Value)) .SelectMany(x => x.Changes) .OrderBy(x => x.PullRequest); var changelist = new Changelist { Version = componentKvp.Value, Changes = changesEnumerator .ToList(), }; if (changelist.Changes.Any()) currentDic.Add(component, changelist); } catch when (Debugger.IsAttached) { Debugger.Break(); } } } var builder = new StringBuilder(); foreach (var releaseDictionary in currentReleaseChangelists) { var allPrNumbers = releaseDictionary.Values.SelectMany(x => x.Changes.Select(y => y.PullRequest)).Distinct().OrderBy(x => x).ToList(); var allPrTasks = allPrNumbers .Select(x => GetPR(client, x)) .ToList(); await Task.WhenAll(allPrTasks); var prDict = allPrTasks.ToDictionary(x => x.Result.Number, x => x.Result); bool AnyPRHasLabel(string labelName) => prDict.Values.Any(x => x.Labels.Any(y => y.Name == labelName)); // determine urgency string urgency; if (AnyPRHasLabel("Priority: CRITICAL")) urgency = "critical"; else if (AnyPRHasLabel("Priority: High")) urgency = "high"; else if (AnyPRHasLabel("Fix")) urgency = "medium"; else urgency = "low"; builder.Append($"tgstation-server ("); builder.Append(releaseDictionary[Component.Core].Version); builder.Append("-1) unstable; urgency="); builder.Append(urgency); foreach (var kvp in releaseDictionary.Where(x => x.Value.Changes.Count > 0 || x.Key == Component.Configuration)) { builder.AppendLine(); builder.AppendLine(); builder.Append(" * The following changes are for "); builder.Append(GetComponentDisplayName(kvp.Key, true)); if (kvp.Key == Component.Configuration) { builder.Append(". You "); if (kvp.Value.Version.Minor == 0 && kvp.Value.Version.Build == 0) builder.Append("will need to"); else builder.Append("should"); builder.Append(" update your `General:ConfigVersion` setting in `/etc/tgstation-server/appsettings.Production.yml` to this new version"); } builder.Append(':'); PrintChanges(builder, kvp.Value, true); } builder.AppendLine(); builder.Append(" -- "); GitHubCommit currentRef; var tags = await tagsTask; var releaseTag = tags.FirstOrDefault(x => x.Name == $"tgstation-server-v{releaseDictionary[Component.Core].Version}"); if (releaseTag != null) currentRef = await client.Repository.Commit.Get(RepoOwner, RepoName, releaseTag.Commit.Sha); else currentRef = await currentRefTask; var committer = currentRef.Commit.Committer; if (committer.Name == "GitHub" && committer.Email == "noreply@github.com") committer = currentRef.Commit.Author; builder.Append(committer.Name); builder.Append(" <"); builder.Append(committer.Email); builder.Append("> "); var commitTime = currentRef.Commit.Committer.Date; builder.Append(commitTime.ToString("ddd").TrimEnd('.')); builder.Append(", "); builder.Append(commitTime.ToString("dd")); builder.Append(' '); builder.Append(commitTime.ToString("MMM").TrimEnd('.')); builder.Append(' '); builder.AppendLine(commitTime.ToString("yyyy HH:mm:ss zz00")); } var changelog = builder.ToString().Replace("\r", String.Empty); await File.WriteAllTextAsync(outputPath, changelog); return 0; } static void DebugAssert(bool condition, string message = null) { // This exists because one of the fucking asserts evaluates an enumerable or something and it was getting optimized out in release // I CBA to track this down. if (message != null) Debug.Assert(condition, message); else Debug.Assert(condition); } } }