using Octokit; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Xml.Linq; namespace ReleaseNotes { /// /// Contains the application entrypoint /// static class Program { /// /// 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]; if (!Version.TryParse(versionString, out var version) || version.Revision != -1) { Console.WriteLine("Invalid version: " + versionString); return 2; } var doNotCloseMilestone = args.Length > 1 && args[1].ToUpperInvariant() == "--NO-CLOSE"; var propsPath = "../../build/Version.props"; if (args.Length > 1 && !doNotCloseMilestone) propsPath = args[1]; var controlPanelPropsPath = "../../build/ControlPanelVersion.props"; if (args.Length > 2 && !doNotCloseMilestone) propsPath = args[2]; const string ReleaseNotesEnvVar = "TGS4_RELEASE_NOTES_TOKEN"; var githubToken = Environment.GetEnvironmentVariable(ReleaseNotesEnvVar); if (String.IsNullOrWhiteSpace(githubToken) && !doNotCloseMilestone) { Console.WriteLine("Missing " + ReleaseNotesEnvVar + " environment variable!"); return 3; } try { var client = new GitHubClient(new ProductHeaderValue("tgs_release_notes")); if (!String.IsNullOrWhiteSpace(githubToken)) { client.Credentials = new Credentials(githubToken); } const string RepoOwner = "tgstation"; const string RepoName = "tgstation-server"; 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, State = ItemState.Closed, 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"); Task milestoneTask = null; var milestoneTaskLock = new object(); var releaseDictionary = new Dictionary>>(StringComparer.OrdinalIgnoreCase); var authorizedUsers = new Dictionary>(); bool postControlPanelMessage = false; async Task GetReleaseNotesFromPR(Issue pullRequest) { //need to check it was merged var fullPR = await client.Repository.PullRequest.Get(RepoOwner, RepoName, pullRequest.Number).ConfigureAwait(false); async Task GetMilestone() { if (fullPR.Milestone == null) return null; return await client.Issue.Milestone.Get(RepoOwner, RepoName, fullPR.Milestone.Number); }; lock (milestoneTaskLock) if (milestoneTask == null) milestoneTask = GetMilestone(); // if (!fullPR.Merged) //return; async Task BuildNotesFromComment(string comment, User user) { async Task CommitNotes(string component, List notes) { Task authTask; TaskCompletionSource ourTcs = null; lock (authorizedUsers) { if (!authorizedUsers.TryGetValue(user.Id, out authTask)) { ourTcs = new TaskCompletionSource(); authTask = ourTcs.Task; authorizedUsers.Add(user.Id, authTask); } } if (ourTcs != null) try { //check if the user has access var perm = String.IsNullOrWhiteSpace(githubToken) ? PermissionLevel.Write : (await client.Repository.Collaborator.ReviewPermission(RepoOwner, RepoName, user.Login).ConfigureAwait(false)).Permission; ourTcs.SetResult(perm == PermissionLevel.Write || perm == PermissionLevel.Admin); } catch { ourTcs.SetResult(false); throw; } var authorized = await authTask.ConfigureAwait(false); if (!authorized) return; lock (releaseDictionary) { foreach (var I in notes) Console.WriteLine(component + " #" + fullPR.Number + " - " + I + " (@" + user.Login + ")"); var tupleSelector = notes.Select(note => Tuple.Create(note, fullPR.Number)); if (releaseDictionary.TryGetValue(component, out var currentValues)) currentValues.AddRange(tupleSelector); else releaseDictionary.Add(component, tupleSelector.ToList()); } } var commentSplits = comment.Split('\n'); 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)) { targetComponent = trimmedLine.Substring(4).Trim(); if (targetComponent.Length == 0) targetComponent = "Core"; } continue; } if (trimmedLine.StartsWith("/:cl:", StringComparison.Ordinal)) { await CommitNotes(targetComponent, notes); targetComponent = null; notes.Clear(); continue; } if (trimmedLine.Length == 0) continue; notes.Add(trimmedLine); } } var comments = await client.Issue.Comment.GetAllForIssue(RepoOwner, RepoName, fullPR.Number).ConfigureAwait(false); await Task.WhenAll(BuildNotesFromComment(fullPR.Body, fullPR.User), Task.WhenAll(comments.Select(x => BuildNotesFromComment(x.Body, x.User)))).ConfigureAwait(false); } var tasks = new List(); foreach (var I in milestonePRs.Items) tasks.Add(GetReleaseNotesFromPR(I)); var releases = await releasesTask.ConfigureAwait(false); var releasingSuite = version.Major; 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 == releasingSuite && (highestReleaseVersion == null || currentReleaseVersion > highestReleaseVersion) && version != currentReleaseVersion) { highestReleaseVersion = currentReleaseVersion; highestRelease = I; } } if (highestReleaseVersion == null) { Console.WriteLine("Unable to determine highest release version for suite " + releasingSuite + "!"); 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; switch (releasingSuite) { case 4: 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 = doc.Root; var controlPanelXmlNamespace = project.GetDefaultNamespace(); var controlPanelVersionsPropertyGroup = project.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 apiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsApiVersion").Value); var configVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsConfigVersion").Value); var dmApiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsDmapiVersion").Value); var webControlVersion = Version.Parse(controlPanelVersionsPropertyGroup.Element(controlPanelXmlNamespace + "TgsControlPanelVersion").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.{Environment.NewLine}{Environment.NewLine}#### Component Versions\nCore: {coreVersion}\nConfiguration: {configVersion}\nHTTP API: {apiVersion}\nDreamMaker API: {dmApiVersion}\n[Web Control Panel](https://github.com/tgstation/tgstation-server-control-panel): {webControlVersion}\nHost Watchdog: {hostWatchdogVersion}"; break; case 3: prefix = "The /tg/station server suite"; break; default: prefix = "See https://tgstation.github.io/tgstation-server for installation instructions"; break; } 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("]("); var milestone = await milestoneTask.ConfigureAwait(false); if (milestone == null) { Console.WriteLine("Unable to detemine milestone!"); return 9; } newNotes.Append(milestone.HtmlUrl); newNotes.Append("?closed=1)"); newNotes.Append(Environment.NewLine); await Task.WhenAll(tasks).ConfigureAwait(false); if (releaseDictionary.Count == 0) { Console.WriteLine("No release notes for this milestone!"); return 8; } foreach (var I in releaseDictionary.OrderBy(kvp => kvp.Key)) { newNotes.Append(Environment.NewLine); newNotes.Append("#### "); newNotes.Append(I.Key); foreach (var noteTuple in I.Value) { newNotes.Append(Environment.NewLine); newNotes.Append("- "); newNotes.Append(noteTuple.Item1); newNotes.Append(" (#"); newNotes.Append(noteTuple.Item2); newNotes.Append(')'); } newNotes.Append(Environment.NewLine); } newNotes.Append(Environment.NewLine); if (version != new Version(4, 1, 0)) newNotes.Append(oldNotes); const string OutputPath = "release_notes.md"; Console.WriteLine($"Writing out new release notes to {Path.GetFullPath(OutputPath)}..."); var releaseNotes = newNotes.ToString(); await File.WriteAllTextAsync(OutputPath, releaseNotes).ConfigureAwait(false); if (doNotCloseMilestone) Console.WriteLine("Not closing milestone due to parameter!"); else { Console.WriteLine("Closing milestone..."); await client.Issue.Milestone.Update(RepoOwner, RepoName, milestone.Number, new MilestoneUpdate { State = ItemState.Closed }).ConfigureAwait(false); // 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)); 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 }); var milestoneToDelete = milestones.FirstOrDefault(x => x.Title.StartsWith($"v{version.Major}.{version.Minor - 1}.")); if (milestoneToDelete != null) { Console.WriteLine($"Moving {milestoneToDelete.OpenIssues} open issues and {milestoneToDelete.ClosedIssues} closed issues from unused patch milestone {milestoneToDelete.Title} to upcoming ones and deleting..."); if (milestoneToDelete.OpenIssues + milestoneToDelete.ClosedIssues > 0) { var issuesInUnusedMilestone = await client.Search.SearchIssues(new SearchIssuesRequest { Milestone = milestoneToDelete.Title, Repos = { { RepoOwner, RepoName } } }); foreach(var I in issuesInUnusedMilestone.Items) await client.Issue.Update(RepoOwner, RepoName, I.Number, new IssueUpdate { Milestone = I.State.Value == ItemState.Closed ? milestone.Number : nextPatchMilestone.Number }); } await client.Issue.Milestone.Delete(RepoOwner, RepoName, milestoneToDelete.Number).ConfigureAwait(false); } // Create the next minor milestone var nextMinorMilestone = $"v{version.Major}.{version.Minor + 1}.0"; Console.WriteLine($"Creating milestone {nextMinorMilestone}..."); await client.Issue.Milestone.Create(RepoOwner, RepoName, new NewMilestone(nextMinorMilestone)); } } return 0; } catch (Exception e) { Console.WriteLine(e); return 4; } } } }