diff --git a/src/WingetCreateCLI/Commands/BaseCommand.cs b/src/WingetCreateCLI/Commands/BaseCommand.cs index 4d88eb50..1b3e2049 100644 --- a/src/WingetCreateCLI/Commands/BaseCommand.cs +++ b/src/WingetCreateCLI/Commands/BaseCommand.cs @@ -313,105 +313,142 @@ protected static void DisplayManifestPreview(Manifests manifests) } /// - /// Validates the GitHubToken provided on the command-line, or if not present, the cached token if one exists. - /// Attempts a simple operation against the target repo, and if that fails, then: - /// If token provided on command-line, errors out - /// If not, and cached token was present, then deletes token cache, and starts OAuth flow - /// Otherwise, sets the instance variable to hold the validated token. - /// If no token is present on command-line or in cache, starts the OAuth flow to retrieve one. + /// Creates a new GitHub client using the provided or cached token if present. + /// If the requireToken bool is set to TRUE, OAuth flow can be launched to acquire a new token for the client. + /// The OAuth flow will only be launched if no token is provided in the command line and no token is present in the token cache. /// - /// Boolean to override default behavior and force caching of token. - /// True if the token is now present and valid, false otherwise. - protected async Task SetAndCheckGitHubToken(bool cacheToken = false) + /// Boolean value indicating whether a token is required for the client and whether to initiate an OAuth flow. + /// A boolean value indicating whether a new GitHub client was created and accessed successfully. + protected async Task LoadGitHubClient(bool requireToken = false) { - string cachedToken = null; - bool hasPatToken = !string.IsNullOrEmpty(this.GitHubToken); - string token = this.GitHubToken; + bool isCacheToken = false; - if (!hasPatToken) + if (string.IsNullOrEmpty(this.GitHubToken)) { Logger.Trace("No token parameter, reading cached token"); - token = cachedToken = GitHubOAuth.ReadTokenCache(); + this.GitHubToken = GitHubOAuth.ReadTokenCache(); - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(this.GitHubToken)) { - Logger.Trace("No cached token found."); - Logger.DebugLocalized(nameof(Resources.GitHubAccountMustBeLinked_Message)); - Logger.DebugLocalized(nameof(Resources.ExecutionPaused_Message)); - Console.WriteLine(); - token = await GitHubOAuthLoginFlow(); - if (string.IsNullOrEmpty(token)) + if (requireToken) { - // User must've cancelled OAuth flow, we can't proceed successfully - Logger.WarnLocalized(nameof(Resources.NoTokenResponse_Message)); - return false; + Logger.Trace("No token found in cache, launching OAuth flow"); + if (!await this.GetTokenFromOAuth()) + { + return false; + } } - - Logger.DebugLocalized(nameof(Resources.ResumingCommandExecution_Message)); } else { - Logger.DebugLocalized(nameof(Resources.UsingTokenFromCache_Message)); + isCacheToken = true; } } - this.GitHubClient = new GitHub(token, this.WingetRepoOwner, this.WingetRepo); + if (await this.CheckGitHubTokenAndSetClient()) + { + return true; + } + else + { + if (isCacheToken) + { + GitHubOAuth.DeleteTokenCache(); + } + + return false; + } + } + /// + /// Launches the GitHub OAuth flow and obtains a GitHub token. + /// + /// A boolean value indicating whether the OAuth login flow was successful. + protected async Task GetTokenFromOAuth() + { + Logger.DebugLocalized(nameof(Resources.GitHubAccountMustBeLinked_Message)); + Logger.DebugLocalized(nameof(Resources.ExecutionPaused_Message)); + Console.WriteLine(); + this.GitHubToken = await GitHubOAuthLoginFlow(); + + if (string.IsNullOrEmpty(this.GitHubToken)) + { + // User must've cancelled OAuth flow, we can't proceed successfully + Logger.WarnLocalized(nameof(Resources.NoTokenResponse_Message)); + return false; + } + + this.StoreTokenInCache(); + Logger.DebugLocalized(nameof(Resources.ResumingCommandExecution_Message)); + return true; + } + + /// + /// If the provided token is valid, stores the token in cache. + /// + /// Returns a boolean value indicating whether storing the token in cache was successful. + protected bool StoreTokenInCache() + { try { - Logger.Trace("Checking repo access using OAuth token"); - await this.GitHubClient.CheckAccess(); - Logger.Trace("Access check was successful, proceeding"); - this.GitHubToken = token; + Logger.Trace("Writing token to cache"); + GitHubOAuth.WriteTokenCache(this.GitHubToken); + Logger.InfoLocalized(nameof(Resources.StoringToken_Message)); + } + catch (Exception ex) + { + // Failing to cache the token shouldn't be fatal. + Logger.WarnLocalized(nameof(Resources.WritingCacheTokenFailed_Message), ex.Message); + return false; + } - // Only cache the token if it came from Oauth, instead of PAT parameter or cache - if (cacheToken || (!hasPatToken && token != cachedToken)) - { - try - { - Logger.Trace("Writing token to cache"); - GitHubOAuth.WriteTokenCache(token); - } - catch (Exception ex) - { - // Failing to cache the token shouldn't be fatal. - Logger.WarnLocalized(nameof(Resources.WritingCacheTokenFailed_Message), ex.Message); - } - } + return true; + } - return true; + /// + /// Verifies if the GitHub token has valid access. + /// + /// A boolean value indicating whether the GitHub token had valid access. + protected async Task CheckGitHubTokenAndSetClient() + { + var client = new GitHub(this.GitHubToken, this.WingetRepoOwner, this.WingetRepo); + + try + { + Logger.Trace("Checking repo access using provided token"); + await client.CheckAccess(); + Logger.Trace("Access check was successful, proceeding"); } catch (Exception e) { - if (token == cachedToken) + if (e is AuthorizationException) { - // There's an issue with the cached token, so let's delete it and try again - Logger.WarnLocalized(nameof(Resources.InvalidCachedToken)); - GitHubOAuth.DeleteTokenCache(); - return await this.SetAndCheckGitHubToken(); + Logger.ErrorLocalized(nameof(Resources.InvalidGitHubToken_Message)); } - else if (e is AuthorizationException) + else if (e is RateLimitExceededException) { - Logger.ErrorLocalized(nameof(Resources.Error_Prefix), e.Message); - Logger.ErrorLocalized(nameof(Resources.InvalidTokenError_Message)); - return false; + Logger.ErrorLocalized(nameof(Resources.RateLimitExceeded_Message)); } - else + else if (e is NotFoundException) { - throw; + Logger.ErrorLocalized(nameof(Resources.RepositoryNotFound_Error), this.WingetRepoOwner, this.WingetRepo); } + + return false; } + + this.GitHubClient = client; + return true; } /// /// Submits a pull request with multifile manifests using the user's GitHub access token. /// /// Wrapper object for manifest object models to be submitted. - /// Access token to allow for this tool to submit a pull request on behalf of the user. /// A representing the success of the asynchronous operation. - protected async Task GitHubSubmitManifests(Manifests manifests, string token) + protected async Task GitHubSubmitManifests(Manifests manifests) { - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(this.GitHubToken)) { Logger.WarnLocalized(nameof(Resources.NoTokenProvided_Message)); return false; diff --git a/src/WingetCreateCLI/Commands/NewCommand.cs b/src/WingetCreateCLI/Commands/NewCommand.cs index d502e81a..efd100f8 100644 --- a/src/WingetCreateCLI/Commands/NewCommand.cs +++ b/src/WingetCreateCLI/Commands/NewCommand.cs @@ -1,184 +1,193 @@ -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. - -namespace Microsoft.WingetCreateCLI.Commands -{ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.WingetCreateCLI.Commands +{ using System; - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - using System.IO; - using System.Linq; - using System.Reflection; - using System.Threading.Tasks; - using CommandLine; - using CommandLine.Text; - using Microsoft.WingetCreateCLI.Logging; - using Microsoft.WingetCreateCLI.Properties; - using Microsoft.WingetCreateCLI.Telemetry; - using Microsoft.WingetCreateCLI.Telemetry.Events; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Threading.Tasks; + using CommandLine; + using CommandLine.Text; + using Microsoft.WingetCreateCLI.Logging; + using Microsoft.WingetCreateCLI.Properties; + using Microsoft.WingetCreateCLI.Telemetry; + using Microsoft.WingetCreateCLI.Telemetry.Events; using Microsoft.WingetCreateCore; using Microsoft.WingetCreateCore.Common; - using Microsoft.WingetCreateCore.Models; - using Microsoft.WingetCreateCore.Models.DefaultLocale; - using Microsoft.WingetCreateCore.Models.Installer; + using Microsoft.WingetCreateCore.Models; + using Microsoft.WingetCreateCore.Models.DefaultLocale; + using Microsoft.WingetCreateCore.Models.Installer; using Microsoft.WingetCreateCore.Models.Version; using Newtonsoft.Json; - using Sharprompt; - - /// - /// Command to launch a wizard that prompt users for information to generate a new manifest. - /// - [Verb("new", HelpText = "NewCommand_HelpText", ResourceType = typeof(Resources))] - public class NewCommand : BaseCommand - { - /// - /// The url path to the manifest documentation site. - /// - private const string ManifestDocumentationUrl = "https://github.com/microsoft/winget-cli/blob/master/doc/ManifestSpecv1.0.md"; - - /// - /// Installer types for which we can trust that the detected architecture is correct, so don't need to prompt the user to confirm. - /// - private static readonly InstallerType[] ReliableArchitectureInstallerTypes = new[] { InstallerType.Msix, InstallerType.Appx }; - - /// - /// Gets the usage examples for the New command. - /// - [Usage(ApplicationAlias = ProgramApplicationAlias)] - public static IEnumerable Examples - { - get - { - yield return new Example(Resources.Example_NewCommand_StartFromScratch, new NewCommand { }); + using Sharprompt; + + /// + /// Command to launch a wizard that prompt users for information to generate a new manifest. + /// + [Verb("new", HelpText = "NewCommand_HelpText", ResourceType = typeof(Resources))] + public class NewCommand : BaseCommand + { + /// + /// The url path to the manifest documentation site. + /// + private const string ManifestDocumentationUrl = "https://github.com/microsoft/winget-cli/blob/master/doc/ManifestSpecv1.0.md"; + + /// + /// Installer types for which we can trust that the detected architecture is correct, so don't need to prompt the user to confirm. + /// + private static readonly InstallerType[] ReliableArchitectureInstallerTypes = new[] { InstallerType.Msix, InstallerType.Appx }; + + /// + /// Gets the usage examples for the New command. + /// + [Usage(ApplicationAlias = ProgramApplicationAlias)] + public static IEnumerable Examples + { + get + { + yield return new Example(Resources.Example_NewCommand_StartFromScratch, new NewCommand { }); yield return new Example(Resources.Example_NewCommand_DownloadInstaller, new NewCommand { InstallerUrls = new string[] { "", ", .." } }); - yield return new Example(Resources.Example_NewCommand_SaveLocallyOrSubmit, new NewCommand - { + yield return new Example(Resources.Example_NewCommand_SaveLocallyOrSubmit, new NewCommand + { InstallerUrls = new string[] { "", ", .." }, - OutputDir = "", - GitHubToken = "", - }); - } - } - - /// + OutputDir = "", + GitHubToken = "", + }); + } + } + + /// /// Gets or sets the installer URL(s) used for downloading and parsing the installer file(s). - /// + /// [Value(0, MetaName = "urls", Required = false, HelpText = "InstallerUrl_HelpText", ResourceType = typeof(Resources))] public IEnumerable InstallerUrls { get; set; } - - /// - /// Gets or sets the outputPath where the generated manifest file should be saved to. - /// - [Option('o', "out", Required = false, HelpText = "OutputDirectory_HelpText", ResourceType = typeof(Resources))] - public string OutputDir { get; set; } + + /// + /// Gets or sets the outputPath where the generated manifest file should be saved to. + /// + [Option('o', "out", Required = false, HelpText = "OutputDirectory_HelpText", ResourceType = typeof(Resources))] + public string OutputDir { get; set; } /// /// Gets or sets the GitHub token used to submit a pull request on behalf of the user. /// [Option('t', "token", Required = false, HelpText = "GitHubToken_HelpText", ResourceType = typeof(Resources))] public override string GitHubToken { get => base.GitHubToken; set => base.GitHubToken = value; } - - /// - /// Executes the new command flow. - /// - /// Boolean representing success or fail of the command. - public override async Task Execute() - { - CommandExecutedEvent commandEvent = new CommandExecutedEvent - { - Command = nameof(NewCommand), + + /// + /// Executes the new command flow. + /// + /// Boolean representing success or fail of the command. + public override async Task Execute() + { + CommandExecutedEvent commandEvent = new CommandExecutedEvent + { + Command = nameof(NewCommand), InstallerUrl = string.Join(',', this.InstallerUrls), - HasGitHubToken = !string.IsNullOrEmpty(this.GitHubToken), - }; - - try - { - Prompt.Symbols.Done = new Symbol(string.Empty, string.Empty); - Prompt.Symbols.Prompt = new Symbol(string.Empty, string.Empty); - - Manifests manifests = new Manifests(); + HasGitHubToken = !string.IsNullOrEmpty(this.GitHubToken), + }; + + try + { + Prompt.Symbols.Done = new Symbol(string.Empty, string.Empty); + Prompt.Symbols.Prompt = new Symbol(string.Empty, string.Empty); + + Manifests manifests = new Manifests(); if (!this.InstallerUrls.Any()) { this.InstallerUrls = PromptProperty( - new Installer(), + new Installer(), this.InstallerUrls, - nameof(Installer.InstallerUrl)); + nameof(Installer.InstallerUrl)); Console.Clear(); - } - + } + var packageFiles = await DownloadInstallers(this.InstallerUrls); if (packageFiles == null) - { - return false; - } - + { + return false; + } + if (!PackageParser.ParsePackages( packageFiles, this.InstallerUrls, manifests, out List detectedArchs)) - { - Logger.ErrorLocalized(nameof(Resources.PackageParsing_Error)); - return false; + { + Logger.ErrorLocalized(nameof(Resources.PackageParsing_Error)); + return false; } DisplayMismatchedArchitectures(detectedArchs); - - Console.WriteLine(Resources.NewCommand_Header); - Console.WriteLine(); - Logger.InfoLocalized(nameof(Resources.ManifestDocumentation_HelpText), ManifestDocumentationUrl); - Console.WriteLine(); - Console.WriteLine(Resources.NewCommand_Description); - Console.WriteLine(); - + + Console.WriteLine(Resources.NewCommand_Header); + Console.WriteLine(); + Logger.InfoLocalized(nameof(Resources.ManifestDocumentation_HelpText), ManifestDocumentationUrl); + Console.WriteLine(); + Console.WriteLine(Resources.NewCommand_Description); + Console.WriteLine(); + Logger.DebugLocalized(nameof(Resources.EnterFollowingFields_Message)); bool isManifestValid; - do + do { if (this.WingetRepoOwner == DefaultWingetRepoOwner && - this.WingetRepo == DefaultWingetRepo && - !await this.PromptPackageIdentifierAndCheckDuplicates(manifests)) + this.WingetRepo == DefaultWingetRepo) { - Console.WriteLine(); - Logger.ErrorLocalized(nameof(Resources.PackageIdAlreadyExists_Error)); - return false; + if (await this.LoadGitHubClient()) + { + if (!await this.PromptPackageIdentifierAndCheckDuplicates(manifests)) + { + Console.WriteLine(); + Logger.ErrorLocalized(nameof(Resources.PackageIdAlreadyExists_Error)); + return false; + } + } + else + { + return false; + } } - + PromptPropertiesAndDisplayManifests(manifests); - isManifestValid = ValidateManifestsInTempDir(manifests); - } - while (Prompt.Confirm(Resources.ConfirmManifestCreation_Message)); - - if (string.IsNullOrEmpty(this.OutputDir)) - { - this.OutputDir = Directory.GetCurrentDirectory(); - } - + isManifestValid = ValidateManifestsInTempDir(manifests); + } + while (Prompt.Confirm(Resources.ConfirmManifestCreation_Message)); + + if (string.IsNullOrEmpty(this.OutputDir)) + { + this.OutputDir = Directory.GetCurrentDirectory(); + } + SaveManifestDirToLocalPath(manifests, this.OutputDir); if (isManifestValid && Prompt.Confirm(Resources.ConfirmGitHubSubmitManifest_Message)) - { - if (await this.SetAndCheckGitHubToken()) + { + if (await this.LoadGitHubClient(true)) { - return commandEvent.IsSuccessful = await this.GitHubSubmitManifests(manifests, this.GitHubToken); + return commandEvent.IsSuccessful = await this.GitHubSubmitManifests(manifests); } return false; } else { - Console.WriteLine(); - Logger.WarnLocalized(nameof(Resources.SkippingPullRequest_Message)); + Console.WriteLine(); + Logger.WarnLocalized(nameof(Resources.SkippingPullRequest_Message)); return commandEvent.IsSuccessful = isManifestValid; - } - } - finally - { - TelemetryManager.Log.WriteEvent(commandEvent); - } + } + } + finally + { + TelemetryManager.Log.WriteEvent(commandEvent); + } } private static void PromptPropertiesAndDisplayManifests(Manifests manifests) @@ -196,51 +205,51 @@ private static void PromptPropertiesAndDisplayManifests(Manifests manifests) Console.WriteLine(); DisplayManifestPreview(manifests); } - - private static void PromptRequiredProperties(T manifest, VersionManifest versionManifest = null) - { - var properties = manifest.GetType().GetProperties().ToList(); - var requiredProperties = properties.Where(p => p.GetCustomAttribute() != null).ToList(); - - foreach (var property in requiredProperties) - { - if (property.PropertyType.IsGenericType) - { - // Generic logic for handling nested object models - Type itemType = property.GetValue(manifest).GetType().GetGenericArguments().Single(); - - if (itemType.Name == nameof(Installer)) - { - PromptInstallerProperties(manifest, property); - } - } - else if (property.PropertyType.IsValueType || property.PropertyType == typeof(string)) - { + + private static void PromptRequiredProperties(T manifest, VersionManifest versionManifest = null) + { + var properties = manifest.GetType().GetProperties().ToList(); + var requiredProperties = properties.Where(p => p.GetCustomAttribute() != null).ToList(); + + foreach (var property in requiredProperties) + { + if (property.PropertyType.IsGenericType) + { + // Generic logic for handling nested object models + Type itemType = property.GetValue(manifest).GetType().GetGenericArguments().Single(); + + if (itemType.Name == nameof(Installer)) + { + PromptInstallerProperties(manifest, property); + } + } + else if (property.PropertyType.IsValueType || property.PropertyType == typeof(string)) + { if (property.Name == nameof(VersionManifest.PackageIdentifier) || property.Name == nameof(VersionManifest.ManifestType) || - property.Name == nameof(VersionManifest.ManifestVersion)) - { - continue; - } - - if (property.Name == nameof(VersionManifest.PackageVersion) && versionManifest != null) - { - property.SetValue(manifest, versionManifest.PackageVersion); - continue; - } - - if (property.Name == nameof(DefaultLocaleManifest.PackageLocale) && versionManifest != null) - { - property.SetValue(manifest, versionManifest.DefaultLocale); - continue; - } - - var currentValue = property.GetValue(manifest); - var result = PromptProperty(manifest, currentValue, property.Name); - property.SetValue(manifest, result); - Logger.Trace($"Property [{property.Name}] set to the value [{result}]"); - } - } + property.Name == nameof(VersionManifest.ManifestVersion)) + { + continue; + } + + if (property.Name == nameof(VersionManifest.PackageVersion) && versionManifest != null) + { + property.SetValue(manifest, versionManifest.PackageVersion); + continue; + } + + if (property.Name == nameof(DefaultLocaleManifest.PackageLocale) && versionManifest != null) + { + property.SetValue(manifest, versionManifest.DefaultLocale); + continue; + } + + var currentValue = property.GetValue(manifest); + var result = PromptProperty(manifest, currentValue, property.Name); + property.SetValue(manifest, result); + Logger.Trace($"Property [{property.Name}] set to the value [{result}]"); + } + } } private static void PromptOptionalProperties(T manifest) @@ -259,13 +268,13 @@ private static void PromptOptionalProperties(T manifest) } } - private static void PromptInstallerProperties(T manifest, PropertyInfo property) - { - List installers = new List((ICollection)property.GetValue(manifest)); + private static void PromptInstallerProperties(T manifest, PropertyInfo property) + { + List installers = new List((ICollection)property.GetValue(manifest)); foreach (var installer in installers) { var installerProperties = installer.GetType().GetProperties().ToList(); - + var requiredInstallerProperties = installerProperties .Where(p => Attribute.IsDefined(p, typeof(RequiredAttribute)) || p.Name == nameof(InstallerType)).ToList(); @@ -295,59 +304,59 @@ private static void PromptInstallerProperties(T manifest, PropertyInfo proper Logger.Debug($"Additional metadata needed for installer from {installer.InstallerUrl}"); prompted = true; } - + var result = PromptProperty(installer, currentValue, requiredProperty.Name); requiredProperty.SetValue(installer, result); - } - } - } - } - - private static void PromptInstallerSwitchesForExe(T manifest) - { - InstallerSwitches installerSwitches = new InstallerSwitches(); - - var silentSwitchResult = PromptProperty(installerSwitches, installerSwitches.Silent, nameof(InstallerSwitches.Silent)); - var silentWithProgressSwitchResult = PromptProperty(installerSwitches, installerSwitches.SilentWithProgress, nameof(InstallerSwitches.SilentWithProgress)); - - bool updateSwitches = false; - - if (!string.IsNullOrEmpty(silentSwitchResult)) - { - installerSwitches.Silent = silentSwitchResult; - updateSwitches = true; - } - - if (!string.IsNullOrEmpty(silentWithProgressSwitchResult)) - { - installerSwitches.SilentWithProgress = silentWithProgressSwitchResult; - updateSwitches = true; - } - - if (updateSwitches) - { - manifest.GetType().GetProperty(nameof(InstallerSwitches)).SetValue(manifest, installerSwitches); - } - } - - private static T PromptProperty(object model, T property, string memberName, string message = null) - { - message ??= $"[{memberName}] " + + } + } + } + } + + private static void PromptInstallerSwitchesForExe(T manifest) + { + InstallerSwitches installerSwitches = new InstallerSwitches(); + + var silentSwitchResult = PromptProperty(installerSwitches, installerSwitches.Silent, nameof(InstallerSwitches.Silent)); + var silentWithProgressSwitchResult = PromptProperty(installerSwitches, installerSwitches.SilentWithProgress, nameof(InstallerSwitches.SilentWithProgress)); + + bool updateSwitches = false; + + if (!string.IsNullOrEmpty(silentSwitchResult)) + { + installerSwitches.Silent = silentSwitchResult; + updateSwitches = true; + } + + if (!string.IsNullOrEmpty(silentWithProgressSwitchResult)) + { + installerSwitches.SilentWithProgress = silentWithProgressSwitchResult; + updateSwitches = true; + } + + if (updateSwitches) + { + manifest.GetType().GetProperty(nameof(InstallerSwitches)).SetValue(manifest, installerSwitches); + } + } + + private static T PromptProperty(object model, T property, string memberName, string message = null) + { + message ??= $"[{memberName}] " + Resources.ResourceManager.GetString($"{memberName}_KeywordDescription") ?? memberName; // Because some properties don't have a current value, we can't rely on T or the property to obtain the type. // Use reflection to obtain the type by looking up the property type by membername based on the model. Type typeFromModel = model.GetType().GetProperty(memberName).PropertyType; - if (typeFromModel.IsEnum) - { - // For enums, we want to call Prompt.Select, specifically the overload that takes 4 parameters - var generic = typeof(Prompt) - .GetMethods() - .Where(mi => mi.Name == nameof(Prompt.Select) && mi.GetParameters().Length == 5) - .Single() - .MakeGenericMethod(property.GetType()); - - return (T)generic.Invoke(null, new object[] { message, property.GetType().GetEnumValues(), null, property, null }); + if (typeFromModel.IsEnum) + { + // For enums, we want to call Prompt.Select, specifically the overload that takes 4 parameters + var generic = typeof(Prompt) + .GetMethods() + .Where(mi => mi.Name == nameof(Prompt.Select) && mi.GetParameters().Length == 5) + .Single() + .MakeGenericMethod(property.GetType()); + + return (T)generic.Invoke(null, new object[] { message, property.GetType().GetEnumValues(), null, property, null }); } else if (typeof(IEnumerable).IsAssignableFrom(typeof(T)) || typeof(IEnumerable).IsAssignableFrom(typeFromModel)) { @@ -362,11 +371,11 @@ private static T PromptProperty(object model, T property, string memberName, string promptResult = Prompt.Input(message, combinedString, new[] { FieldValidation.ValidateProperty(model, memberName, property) }); return (T)(object)promptResult?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } - else - { - var promptResult = Prompt.Input(message, property, new[] { FieldValidation.ValidateProperty(model, memberName, property) }); - return promptResult is string str ? (T)(object)str.Trim() : promptResult; - } + else + { + var promptResult = Prompt.Input(message, property, new[] { FieldValidation.ValidateProperty(model, memberName, property) }); + return promptResult is string str ? (T)(object)str.Trim() : promptResult; + } } /// @@ -377,20 +386,19 @@ private static T PromptProperty(object model, T property, string memberName, /// Boolean value indicating whether the package identifier is valid. private async Task PromptPackageIdentifierAndCheckDuplicates(Manifests manifests) { - GitHub client = new GitHub(this.GitHubToken, this.WingetRepoOwner, this.WingetRepo); - - if (!string.IsNullOrEmpty(this.GitHubToken)) - { - if (!await this.SetAndCheckGitHubToken()) - { - return false; - } - } - VersionManifest versionManifest = manifests.VersionManifest; versionManifest.PackageIdentifier = PromptProperty(versionManifest, versionManifest.PackageIdentifier, nameof(versionManifest.PackageIdentifier)); - string exactMatch = await client.FindPackageId(versionManifest.PackageIdentifier); + string exactMatch; + try + { + exactMatch = await this.GitHubClient.FindPackageId(versionManifest.PackageIdentifier); + } + catch (Octokit.RateLimitExceededException) + { + Logger.ErrorLocalized(nameof(Resources.RateLimitExceeded_Message)); + return false; + } if (!string.IsNullOrEmpty(exactMatch)) { @@ -402,6 +410,6 @@ private async Task PromptPackageIdentifierAndCheckDuplicates(Manifests man manifests.DefaultLocaleManifest.PackageIdentifier = versionManifest.PackageIdentifier; return true; } - } - } -} + } + } +} diff --git a/src/WingetCreateCLI/Commands/SubmitCommand.cs b/src/WingetCreateCLI/Commands/SubmitCommand.cs index a8923bdb..85f8cb82 100644 --- a/src/WingetCreateCLI/Commands/SubmitCommand.cs +++ b/src/WingetCreateCLI/Commands/SubmitCommand.cs @@ -76,7 +76,7 @@ public override async Task Execute() return false; } - if (!await this.SetAndCheckGitHubToken()) + if (!await this.LoadGitHubClient(true)) { return false; } @@ -95,13 +95,13 @@ private async Task SubmitManifest() { Manifests manifests = new Manifests(); manifests.SingletonManifest = Serialization.DeserializeFromPath(this.Path); - return await this.GitHubSubmitManifests(manifests, this.GitHubToken); + return await this.GitHubSubmitManifests(manifests); } else if (Directory.Exists(this.Path) && ValidateManifest(this.Path)) { List manifestContents = Directory.GetFiles(this.Path).Select(f => File.ReadAllText(f)).ToList(); Manifests manifests = Serialization.DeserializeManifestContents(manifestContents); - return await this.GitHubSubmitManifests(manifests, this.GitHubToken); + return await this.GitHubSubmitManifests(manifests); } else { diff --git a/src/WingetCreateCLI/Commands/TokenCommand.cs b/src/WingetCreateCLI/Commands/TokenCommand.cs index 98a0c910..a61ab218 100644 --- a/src/WingetCreateCLI/Commands/TokenCommand.cs +++ b/src/WingetCreateCLI/Commands/TokenCommand.cs @@ -56,8 +56,10 @@ public override async Task Execute() } else if (this.Store) { - Logger.InfoLocalized(nameof(Resources.StoreToken_Message)); - return commandEvent.IsSuccessful = await this.SetAndCheckGitHubToken(true); + Logger.InfoLocalized(nameof(Resources.SettingToken_Message)); + return commandEvent.IsSuccessful = string.IsNullOrEmpty(this.GitHubToken) ? + await this.GetTokenFromOAuth() : + this.StoreTokenInCache(); } return false; diff --git a/src/WingetCreateCLI/Commands/UpdateCommand.cs b/src/WingetCreateCLI/Commands/UpdateCommand.cs index 373e5873..32dd610c 100644 --- a/src/WingetCreateCLI/Commands/UpdateCommand.cs +++ b/src/WingetCreateCLI/Commands/UpdateCommand.cs @@ -16,7 +16,6 @@ namespace Microsoft.WingetCreateCLI.Commands using Microsoft.WingetCreateCLI.Telemetry; using Microsoft.WingetCreateCLI.Telemetry.Events; using Microsoft.WingetCreateCore; - using Microsoft.WingetCreateCore.Common; using Microsoft.WingetCreateCore.Models; using Microsoft.WingetCreateCore.Models.DefaultLocale; using Microsoft.WingetCreateCore.Models.Installer; @@ -110,14 +109,9 @@ public override async Task Execute() return false; } - GitHub client = new GitHub(this.GitHubToken, this.WingetRepoOwner, this.WingetRepo); - - if (!string.IsNullOrEmpty(this.GitHubToken)) + if (!await this.LoadGitHubClient()) { - if (!await this.SetAndCheckGitHubToken()) - { - return false; - } + return false; } Logger.DebugLocalized(nameof(Resources.RetrievingManifest_Message), this.Id); @@ -125,11 +119,11 @@ public override async Task Execute() string exactId; try { - exactId = await client.FindPackageId(this.Id); + exactId = await this.GitHubClient.FindPackageId(this.Id); } - catch (Octokit.NotFoundException) + catch (Octokit.RateLimitExceededException) { - Logger.ErrorLocalized(nameof(Resources.RepositoryNotFound_Error), this.WingetRepoOwner, this.WingetRepo); + Logger.ErrorLocalized(nameof(Resources.RateLimitExceeded_Message)); return false; } @@ -142,7 +136,7 @@ public override async Task Execute() try { - latestManifestContent = await client.GetLatestManifestContentAsync(this.Id); + latestManifestContent = await this.GitHubClient.GetLatestManifestContentAsync(this.Id); } catch (Octokit.NotFoundException e) { @@ -291,8 +285,8 @@ public async Task ExecuteManifestUpdate(List latestManifestContent return false; } - return await this.SetAndCheckGitHubToken() - ? (commandEvent.IsSuccessful = await this.GitHubSubmitManifests(updatedManifests, this.GitHubToken)) + return await this.LoadGitHubClient(true) + ? (commandEvent.IsSuccessful = await this.GitHubSubmitManifests(updatedManifests)) : false; } diff --git a/src/WingetCreateCLI/Properties/Resources.Designer.cs b/src/WingetCreateCLI/Properties/Resources.Designer.cs index 843f5ccd..e161354d 100644 --- a/src/WingetCreateCLI/Properties/Resources.Designer.cs +++ b/src/WingetCreateCLI/Properties/Resources.Designer.cs @@ -276,6 +276,15 @@ public static string DeletingInstaller_Message { } } + /// + /// Looks up a localized string similar to Cached token was invalid, deleting token from cache.... + /// + public static string DeletingInvalidCachedToken_Message { + get { + return ResourceManager.GetString("DeletingInvalidCachedToken_Message", resourceCulture); + } + } + /// /// Looks up a localized string similar to Collection of package dependencies. /// @@ -718,11 +727,11 @@ public static string Interactive_KeywordDescription { } /// - /// Looks up a localized string similar to Cached token was invalid, deleting and launching OAuth flow. + /// Looks up a localized string similar to Token was invalid. Please generate a new GitHub token and try again.. /// - public static string InvalidCachedToken { + public static string InvalidGitHubToken_Message { get { - return ResourceManager.GetString("InvalidCachedToken", resourceCulture); + return ResourceManager.GetString("InvalidGitHubToken_Message", resourceCulture); } } @@ -1230,6 +1239,15 @@ public static string PullRequestURI_Message { } } + /// + /// Looks up a localized string similar to GitHub api rate limit exceeded. To extend your rate limit, provide your GitHub token with the "-t" flag or store one using the "token --store" command.. + /// + public static string RateLimitExceeded_Message { + get { + return ResourceManager.GetString("RateLimitExceeded_Message", resourceCulture); + } + } + /// /// Looks up a localized string similar to Input does not match the valid format pattern for this field.. /// @@ -1293,6 +1311,15 @@ public static string SettingsCommand_HelpText { } } + /// + /// Looks up a localized string similar to Setting GitHub token.... + /// + public static string SettingToken_Message { + get { + return ResourceManager.GetString("SettingToken_Message", resourceCulture); + } + } + /// /// Looks up a localized string similar to The SHA256 installer hash. /// @@ -1357,11 +1384,11 @@ public static string Store_HelpText { } /// - /// Looks up a localized string similar to Setting cached GitHub token. + /// Looks up a localized string similar to Token stored in cache successfully.. /// - public static string StoreToken_Message { + public static string StoringToken_Message { get { - return ResourceManager.GetString("StoreToken_Message", resourceCulture); + return ResourceManager.GetString("StoringToken_Message", resourceCulture); } } @@ -1527,15 +1554,6 @@ public static string Url_KeywordDescription { } } - /// - /// Looks up a localized string similar to Using GitHub token from cache.... - /// - public static string UsingTokenFromCache_Message { - get { - return ResourceManager.GetString("UsingTokenFromCache_Message", resourceCulture); - } - } - /// /// Looks up a localized string similar to Please check and verify the usage of this command by passing in the --help flag.. /// @@ -1582,7 +1600,7 @@ public static string WindowsLibraries_KeywordDescription { } /// - /// Looks up a localized string similar to Writing the OAuth token to cache failed: {0}. + /// Looks up a localized string similar to Writing the token to cache failed: {0}. /// public static string WritingCacheTokenFailed_Message { get { diff --git a/src/WingetCreateCLI/Properties/Resources.resx b/src/WingetCreateCLI/Properties/Resources.resx index b208e2eb..6ff1e408 100644 --- a/src/WingetCreateCLI/Properties/Resources.resx +++ b/src/WingetCreateCLI/Properties/Resources.resx @@ -268,9 +268,6 @@ The interactive installer switches - - Cached token was invalid, deleting and launching OAuth flow - Overrides default language @@ -380,9 +377,6 @@ The silent installer switches - - Setting cached GitHub token - Set the cached GitHub token. Can specify token to cache with --token parameter, otherwise will initiate OAuth flow. @@ -429,7 +423,7 @@ The package version |e.g. 1.2.3.4| - Writing the OAuth token to cache failed: {0} + Writing the token to cache failed: {0} Would you like to make changes to this manifest? @@ -459,9 +453,6 @@ Resuming command execution... - - Using GitHub token from cache... - The package name |e.g. Visual Studio| @@ -658,4 +649,21 @@ Installer cache cleaned. + + Cached token was invalid, deleting token from cache... + + + Token was invalid. Please generate a new GitHub token and try again. + + + GitHub api rate limit exceeded. To extend your rate limit, provide your GitHub token with the "-t" flag or store one using the "token --store" command. + "-t" refers to a command line switch argument +"token --store" refers to a command line argument + + + Setting GitHub token... + + + Token stored in cache successfully. + \ No newline at end of file diff --git a/src/WingetCreateCore/Common/GitHub.cs b/src/WingetCreateCore/Common/GitHub.cs index e6cbf318..7e547a7f 100644 --- a/src/WingetCreateCore/Common/GitHub.cs +++ b/src/WingetCreateCore/Common/GitHub.cs @@ -1,205 +1,205 @@ -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. - -namespace Microsoft.WingetCreateCore.Common -{ - using System; +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.WingetCreateCore.Common +{ + using System; using System.Collections.Generic; using System.IO; - using System.Linq; - using System.Security.Cryptography; - using System.Threading.Tasks; - using Jose; - using Microsoft.WingetCreateCore.Models; - using Octokit; - - /// - /// Provides functionality for interacting a user's GitHub account. - /// - public class GitHub - { - private const string HeadMasterRef = "heads/master"; - private const string PRDescriptionRepoPath = ".github/PULL_REQUEST_TEMPLATE.md"; - private const string UserAgentName = "WingetCreate"; - private readonly GitHubClient github; - private readonly string wingetRepoOwner; - private readonly string wingetRepo; - - /// - /// Initializes a new instance of the class. - /// - /// GitHub access token. - /// Winget repository owner. - /// Winget repository. - public GitHub(string githubApiToken, string wingetRepoOwner, string wingetRepo) - { - this.wingetRepoOwner = wingetRepoOwner; - this.wingetRepo = wingetRepo; - this.github = new GitHubClient(new ProductHeaderValue(UserAgentName)); - if (githubApiToken != null) - { - this.github.Credentials = new Credentials(githubApiToken, AuthenticationType.Bearer); - } - } - - /// - /// Gets an access token to use for GitHub operations performed from a GitHub app context. - /// - /// The private key for the GitHub app in PEM format. - /// The id for the GitHub app. - /// Winget repository owner. - /// Winget repository. - /// GitHub app installation access token to use for GitHub operations. - public static async Task GetGitHubAppInstallationAccessToken(string gitHubAppPrivateKeyPem, int gitHubAppId, string wingetRepoOwner, string wingetRepo) - { - string jwtToken = GetJwtToken(gitHubAppPrivateKeyPem, gitHubAppId); - - var github = new GitHubClient(new ProductHeaderValue(UserAgentName)); - github.Credentials = new Credentials(jwtToken, AuthenticationType.Bearer); - - var installation = await github.GitHubApps.GetRepositoryInstallationForCurrent(wingetRepoOwner, wingetRepo); - var response = await github.GitHubApps.CreateInstallationToken(installation.Id); - return response.Token; - } - - /// - /// Gets all app manifests in the repo. - /// - /// A list of , each representing a single app manifest version. - public async Task> GetAppVersions() - { - var reference = await this.github.Git.Reference.Get(this.wingetRepoOwner, this.wingetRepo, HeadMasterRef); - var tree = await this.github.Git.Tree.GetRecursive(this.wingetRepoOwner, this.wingetRepo, reference.Object.Sha); - return tree.Tree - .Where(i => i.Path.StartsWith(Constants.WingetManifestRoot + "/") && i.Type.Value == TreeType.Blob) - .Select(i => new { i.Path, PathTokens = i.Path[Constants.WingetManifestRoot.Length..].Split('/') }) - .Where(i => i.PathTokens.Length >= 3) - .Select(i => - { - // Substring path will be in the form of - // Microsoft/PowerToys/0.15.2.yaml, or - // Microsoft/VisualStudio/Community/16.0.30011.22.yaml - string publisher = i.PathTokens[0]; - string version = i.PathTokens[^1].Replace(".yaml", string.Empty, StringComparison.OrdinalIgnoreCase); - string app = string.Join('.', i.PathTokens[1..^1]); - return new PublisherAppVersion(publisher, app, version, $"{publisher}.{app}", i.Path); - }) - .ToList(); + using System.Linq; + using System.Security.Cryptography; + using System.Threading.Tasks; + using Jose; + using Microsoft.WingetCreateCore.Models; + using Octokit; + + /// + /// Provides functionality for interacting a user's GitHub account. + /// + public class GitHub + { + private const string HeadMasterRef = "heads/master"; + private const string PRDescriptionRepoPath = ".github/PULL_REQUEST_TEMPLATE.md"; + private const string UserAgentName = "WingetCreate"; + private readonly GitHubClient github; + private readonly string wingetRepoOwner; + private readonly string wingetRepo; + + /// + /// Initializes a new instance of the class. + /// + /// GitHub access token. + /// Winget repository owner. + /// Winget repository. + public GitHub(string githubApiToken, string wingetRepoOwner, string wingetRepo) + { + this.wingetRepoOwner = wingetRepoOwner; + this.wingetRepo = wingetRepo; + this.github = new GitHubClient(new ProductHeaderValue(UserAgentName)); + if (githubApiToken != null) + { + this.github.Credentials = new Credentials(githubApiToken, AuthenticationType.Bearer); + } + } + + /// + /// Gets an access token to use for GitHub operations performed from a GitHub app context. + /// + /// The private key for the GitHub app in PEM format. + /// The id for the GitHub app. + /// Winget repository owner. + /// Winget repository. + /// GitHub app installation access token to use for GitHub operations. + public static async Task GetGitHubAppInstallationAccessToken(string gitHubAppPrivateKeyPem, int gitHubAppId, string wingetRepoOwner, string wingetRepo) + { + string jwtToken = GetJwtToken(gitHubAppPrivateKeyPem, gitHubAppId); + + var github = new GitHubClient(new ProductHeaderValue(UserAgentName)); + github.Credentials = new Credentials(jwtToken, AuthenticationType.Bearer); + + var installation = await github.GitHubApps.GetRepositoryInstallationForCurrent(wingetRepoOwner, wingetRepo); + var response = await github.GitHubApps.CreateInstallationToken(installation.Id); + return response.Token; + } + + /// + /// Gets all app manifests in the repo. + /// + /// A list of , each representing a single app manifest version. + public async Task> GetAppVersions() + { + var reference = await this.github.Git.Reference.Get(this.wingetRepoOwner, this.wingetRepo, HeadMasterRef); + var tree = await this.github.Git.Tree.GetRecursive(this.wingetRepoOwner, this.wingetRepo, reference.Object.Sha); + return tree.Tree + .Where(i => i.Path.StartsWith(Constants.WingetManifestRoot + "/") && i.Type.Value == TreeType.Blob) + .Select(i => new { i.Path, PathTokens = i.Path[Constants.WingetManifestRoot.Length..].Split('/') }) + .Where(i => i.PathTokens.Length >= 3) + .Select(i => + { + // Substring path will be in the form of + // Microsoft/PowerToys/0.15.2.yaml, or + // Microsoft/VisualStudio/Community/16.0.30011.22.yaml + string publisher = i.PathTokens[0]; + string version = i.PathTokens[^1].Replace(".yaml", string.Empty, StringComparison.OrdinalIgnoreCase); + string app = string.Join('.', i.PathTokens[1..^1]); + return new PublisherAppVersion(publisher, app, version, $"{publisher}.{app}", i.Path); + }) + .ToList(); + } + + /// + /// Obtains the latest manifest using the specified packageId. + /// + /// PackageId of the manifest to be retrieved. + /// Manifest as a string. + public async Task> GetLatestManifestContentAsync(string packageId) + { + string appPath = Utils.GetAppManifestDirPath(packageId, string.Empty, '/'); + var contents = await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, appPath); + + string version = contents + .Where(c => c.Type == ContentType.Dir) + .OrderByDescending(c => c.Name, new VersionComparer()) + .Select(c => c.Path) + .FirstOrDefault(); + + var packageContents = (await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, version)) + .Where(c => c.Type != ContentType.Dir && Path.GetExtension(c.Name).EqualsIC(".yaml")); + + // If all contents of version directory are directories themselves, user must've provided an invalid packageId. + if (!packageContents.Any()) + { + throw new NotFoundException(nameof(packageId), System.Net.HttpStatusCode.NotFound); + } + + List manifestContent = new List(); + + foreach (RepositoryContent content in packageContents) + { + string fileContent = await this.GetFileContentsAsync(content.Path); + manifestContent.Add(fileContent); + } + + return manifestContent; } - - /// - /// Obtains the latest manifest using the specified packageId. - /// - /// PackageId of the manifest to be retrieved. - /// Manifest as a string. - public async Task> GetLatestManifestContentAsync(string packageId) - { - string appPath = Utils.GetAppManifestDirPath(packageId, string.Empty, '/'); - var contents = await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, appPath); - - string version = contents - .Where(c => c.Type == ContentType.Dir) - .OrderByDescending(c => c.Name, new VersionComparer()) - .Select(c => c.Path) - .FirstOrDefault(); - - var packageContents = (await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, version)) - .Where(c => c.Type != ContentType.Dir && string.Equals(Path.GetExtension(c.Name), ".yaml", StringComparison.OrdinalIgnoreCase)); - - // If all contents of version directory are directories themselves, user must've provided an invalid packageId. - if (!packageContents.Any()) - { - throw new NotFoundException(nameof(packageId), System.Net.HttpStatusCode.NotFound); - } - - List manifestContent = new List(); - - foreach (RepositoryContent content in packageContents) + + /// + /// Submits a pull request on behalf of the user. + /// + /// Wrapper object for manifest object models to be submitted in the PR. + /// Bool indicating whether or not to submit the PR via a fork. + /// Pull request object. + public Task SubmitPullRequestAsync(Manifests manifests, bool submitToFork) + { + Dictionary contents = new Dictionary(); + string id; + string version; + + if (manifests.SingletonManifest != null) { - string fileContent = await this.GetFileContentsAsync(content.Path); - manifestContent.Add(fileContent); - } - - return manifestContent; - } - - /// - /// Submits a pull request on behalf of the user. - /// - /// Wrapper object for manifest object models to be submitted in the PR. - /// Bool indicating whether or not to submit the PR via a fork. - /// Pull request object. - public Task SubmitPullRequestAsync(Manifests manifests, bool submitToFork) - { - Dictionary contents = new Dictionary(); - string id; - string version; - - if (manifests.SingletonManifest != null) - { - id = manifests.SingletonManifest.PackageIdentifier; - version = manifests.SingletonManifest.PackageVersion; - contents.Add(manifests.SingletonManifest.PackageIdentifier, manifests.SingletonManifest.ToYaml()); - } - else - { - id = manifests.VersionManifest.PackageIdentifier; - version = manifests.VersionManifest.PackageVersion; - - contents = manifests.LocaleManifests.ToDictionary(locale => $"{id}.locale.{locale.PackageLocale}", locale => locale.ToYaml()); - - contents.Add(id, manifests.VersionManifest.ToYaml()); - contents.Add($"{id}.installer", manifests.InstallerManifest.ToYaml()); - contents.Add($"{id}.locale.{manifests.DefaultLocaleManifest.PackageLocale}", manifests.DefaultLocaleManifest.ToYaml()); - } - - return this.SubmitPRAsync(id, version, contents, submitToFork); - } - - /// - /// Closes an open pull request and deletes its branch if not on forked repo. - /// - /// The pull request number. - /// A representing the asynchronous operation. - public async Task ClosePullRequest(int pullRequestId) - { - // Close PR and delete its branch. - await this.github.PullRequest.Update(this.wingetRepoOwner, this.wingetRepo, pullRequestId, new PullRequestUpdate() { State = ItemState.Closed }); - await this.DeletePullRequestBranch(pullRequestId); - } - - /// - /// Merges an open pull request. - /// - /// The pull request number. - /// A representing the asynchronous operation. - public async Task MergePullRequest(int pullRequestId) - { - await this.github.PullRequest.Merge(this.wingetRepoOwner, this.wingetRepo, pullRequestId, new MergePullRequest()); - await this.DeletePullRequestBranch(pullRequestId); - } - - /// - /// Retrieves file contents from a specified GitHub path. - /// - /// GitHub path where the files should be retrieved from. - /// Contents from the specified GitHub path. - public async Task GetFileContentsAsync(string path) - { - var contents = (await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, path)) - .Select(f => f.Content) - .First(); - - return contents; - } - - /// - /// Checks that the GitHub client can perform operations against the repo using the auth token. - /// - /// A representing the asynchronous operation. - public async Task CheckAccess() - { - await this.github.Repository.Get(this.wingetRepoOwner, this.wingetRepo); + id = manifests.SingletonManifest.PackageIdentifier; + version = manifests.SingletonManifest.PackageVersion; + contents.Add(manifests.SingletonManifest.PackageIdentifier, manifests.SingletonManifest.ToYaml()); + } + else + { + id = manifests.VersionManifest.PackageIdentifier; + version = manifests.VersionManifest.PackageVersion; + + contents = manifests.LocaleManifests.ToDictionary(locale => $"{id}.locale.{locale.PackageLocale}", locale => locale.ToYaml()); + + contents.Add(id, manifests.VersionManifest.ToYaml()); + contents.Add($"{id}.installer", manifests.InstallerManifest.ToYaml()); + contents.Add($"{id}.locale.{manifests.DefaultLocaleManifest.PackageLocale}", manifests.DefaultLocaleManifest.ToYaml()); + } + + return this.SubmitPRAsync(id, version, contents, submitToFork); + } + + /// + /// Closes an open pull request and deletes its branch if not on forked repo. + /// + /// The pull request number. + /// A representing the asynchronous operation. + public async Task ClosePullRequest(int pullRequestId) + { + // Close PR and delete its branch. + await this.github.PullRequest.Update(this.wingetRepoOwner, this.wingetRepo, pullRequestId, new PullRequestUpdate() { State = ItemState.Closed }); + await this.DeletePullRequestBranch(pullRequestId); + } + + /// + /// Merges an open pull request. + /// + /// The pull request number. + /// A representing the asynchronous operation. + public async Task MergePullRequest(int pullRequestId) + { + await this.github.PullRequest.Merge(this.wingetRepoOwner, this.wingetRepo, pullRequestId, new MergePullRequest()); + await this.DeletePullRequestBranch(pullRequestId); + } + + /// + /// Retrieves file contents from a specified GitHub path. + /// + /// GitHub path where the files should be retrieved from. + /// Contents from the specified GitHub path. + public async Task GetFileContentsAsync(string path) + { + var contents = (await this.github.Repository.Content.GetAllContents(this.wingetRepoOwner, this.wingetRepo, path)) + .Select(f => f.Content) + .First(); + + return contents; + } + + /// + /// Checks that the GitHub client can perform operations against the repo using the auth token. + /// + /// A representing the asynchronous operation. + public async Task CheckAccess() + { + await this.github.Repository.Get(this.wingetRepoOwner, this.wingetRepo); } /// @@ -211,32 +211,32 @@ public async Task FindPackageId(string packageId) { string path = Constants.WingetManifestRoot + '/' + $"{char.ToLowerInvariant(packageId[0])}"; return await this.FindPackageIdRecursive(packageId.Split('.'), path, string.Empty, 0); - } - - /// - /// Generate a signed-JWT token for specified GitHub app, per instructions here: https://docs.github.com/en/developers/apps/authenticating-with-github-apps#authenticating-as-an-installation. - /// - /// The private key for the GitHub app in PEM format. - /// The id for the GitHub app. - /// Signed JWT token, expiring in 10 minutes. - private static string GetJwtToken(string gitHubAppPrivateKeyPem, int gitHubAppId) - { - var rsa = RSA.Create(); - rsa.ImportFromPem(gitHubAppPrivateKeyPem); - - var payload = new - { - // issued at time, 60 seconds in the past to allow for clock drift - iat = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds(), - - // JWT expiration time (10 minute maximum) - exp = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds(), - - // GitHub App's identifier - iss = gitHubAppId, - }; - - return JWT.Encode(payload, rsa, JwsAlgorithm.RS256); + } + + /// + /// Generate a signed-JWT token for specified GitHub app, per instructions here: https://docs.github.com/en/developers/apps/authenticating-with-github-apps#authenticating-as-an-installation. + /// + /// The private key for the GitHub app in PEM format. + /// The id for the GitHub app. + /// Signed JWT token, expiring in 10 minutes. + private static string GetJwtToken(string gitHubAppPrivateKeyPem, int gitHubAppId) + { + var rsa = RSA.Create(); + rsa.ImportFromPem(gitHubAppPrivateKeyPem); + + var payload = new + { + // issued at time, 60 seconds in the past to allow for clock drift + iat = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds(), + + // JWT expiration time (10 minute maximum) + exp = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds(), + + // GitHub App's identifier + iss = gitHubAppId, + }; + + return JWT.Encode(payload, rsa, JwsAlgorithm.RS256); } private async Task FindPackageIdRecursive(string[] packageId, string path, string exactPackageId, int index) @@ -261,99 +261,99 @@ private async Task FindPackageIdRecursive(string[] packageId, string pat } return null; - } - - private async Task SubmitPRAsync(string packageId, string version, Dictionary contents, bool submitToFork) - { - bool createdRepo = false; - - Repository repo; - if (submitToFork) - { - try - { - var user = await this.github.User.Current(); - repo = await this.github.Repository.Get(user.Login, this.wingetRepo); - } - catch (NotFoundException) - { - repo = await this.github.Repository.Forks.Create(this.wingetRepoOwner, this.wingetRepo, new NewRepositoryFork()); - createdRepo = true; - } - } - else - { - repo = await this.github.Repository.Get(this.wingetRepoOwner, this.wingetRepo); - } - - string newBranchName = $"autogenerated/{packageId}/{Guid.NewGuid()}"; - string newBranchNameHeads = $"heads/{newBranchName}"; - - string message = $"{packageId} version {version}"; - - var upstreamMaster = await this.github.Git.Reference.Get(this.wingetRepoOwner, this.wingetRepo, HeadMasterRef); - var upstreamMasterSha = upstreamMaster.Object.Sha; - - Reference newBranch = null; - try - { - // Create new branch synced to upstream master - await this.github.Git.Reference.Create(repo.Id, new NewReference($"refs/{newBranchNameHeads}", upstreamMasterSha)); - - // Update from upstream branch master - newBranch = await this.github.Git.Reference.Update(repo.Id, newBranchNameHeads, new ReferenceUpdate(upstreamMasterSha)); - var updatedSha = newBranch.Object.Sha; - - var nt = new NewTree { BaseTree = updatedSha }; - string appPath = Utils.GetAppManifestDirPath(packageId, version, '/'); - - foreach (KeyValuePair item in contents) - { - string file = $"{appPath}/{item.Key}.yaml"; - nt.Tree.Add(new NewTreeItem { Path = file, Mode = "100644", Type = TreeType.Blob, Content = item.Value }); - } - - var newTree = await this.github.Git.Tree.Create(repo.Id, nt); - - var newCommit = new NewCommit(message, newTree.Sha, updatedSha); - var commit = await this.github.Git.Commit.Create(repo.Id, newCommit); - - await this.github.Git.Reference.Update(repo.Id, newBranchNameHeads, new ReferenceUpdate(commit.Sha)); - - // Get latest description template from repo - string description = await this.GetFileContentsAsync(PRDescriptionRepoPath); - - string targetBranch = submitToFork ? repo.Parent.DefaultBranch : repo.DefaultBranch; - var newPullRequest = new NewPullRequest(message, $"{repo.Owner.Login}:{newBranchName}", targetBranch) { Body = description }; - var pullRequest = await this.github.PullRequest.Create(this.wingetRepoOwner, this.wingetRepo, newPullRequest); - - return pullRequest; - } - catch (Exception) - { - // On error, cleanup created branch/repo before re-throwing - if (createdRepo) - { - await this.github.Repository.Delete(repo.Id); - } - else if (newBranch != null) - { - await this.github.Git.Reference.Delete(repo.Id, newBranch.Ref); - } - - throw; - } - } - - private async Task DeletePullRequestBranch(int pullRequestId) - { - // Delete branch if it's not on a forked repo. - var pullRequest = await this.github.PullRequest.Get(this.wingetRepoOwner, this.wingetRepo, pullRequestId); - if (pullRequest.Base.Repository.Id == pullRequest.Head.Repository.Id) - { - string newBranchNameHeads = $"heads/{pullRequest.Head.Ref}"; - await this.github.Git.Reference.Delete(this.wingetRepoOwner, this.wingetRepo, newBranchNameHeads); - } - } - } -} + } + + private async Task SubmitPRAsync(string packageId, string version, Dictionary contents, bool submitToFork) + { + bool createdRepo = false; + + Repository repo; + if (submitToFork) + { + try + { + var user = await this.github.User.Current(); + repo = await this.github.Repository.Get(user.Login, this.wingetRepo); + } + catch (NotFoundException) + { + repo = await this.github.Repository.Forks.Create(this.wingetRepoOwner, this.wingetRepo, new NewRepositoryFork()); + createdRepo = true; + } + } + else + { + repo = await this.github.Repository.Get(this.wingetRepoOwner, this.wingetRepo); + } + + string newBranchName = $"autogenerated/{packageId}/{Guid.NewGuid()}"; + string newBranchNameHeads = $"heads/{newBranchName}"; + + string message = $"{packageId} version {version}"; + + var upstreamMaster = await this.github.Git.Reference.Get(this.wingetRepoOwner, this.wingetRepo, HeadMasterRef); + var upstreamMasterSha = upstreamMaster.Object.Sha; + + Reference newBranch = null; + try + { + // Create new branch synced to upstream master + await this.github.Git.Reference.Create(repo.Id, new NewReference($"refs/{newBranchNameHeads}", upstreamMasterSha)); + + // Update from upstream branch master + newBranch = await this.github.Git.Reference.Update(repo.Id, newBranchNameHeads, new ReferenceUpdate(upstreamMasterSha)); + var updatedSha = newBranch.Object.Sha; + + var nt = new NewTree { BaseTree = updatedSha }; + string appPath = Utils.GetAppManifestDirPath(packageId, version, '/'); + + foreach (KeyValuePair item in contents) + { + string file = $"{appPath}/{item.Key}.yaml"; + nt.Tree.Add(new NewTreeItem { Path = file, Mode = "100644", Type = TreeType.Blob, Content = item.Value }); + } + + var newTree = await this.github.Git.Tree.Create(repo.Id, nt); + + var newCommit = new NewCommit(message, newTree.Sha, updatedSha); + var commit = await this.github.Git.Commit.Create(repo.Id, newCommit); + + await this.github.Git.Reference.Update(repo.Id, newBranchNameHeads, new ReferenceUpdate(commit.Sha)); + + // Get latest description template from repo + string description = await this.GetFileContentsAsync(PRDescriptionRepoPath); + + string targetBranch = submitToFork ? repo.Parent.DefaultBranch : repo.DefaultBranch; + var newPullRequest = new NewPullRequest(message, $"{repo.Owner.Login}:{newBranchName}", targetBranch) { Body = description }; + var pullRequest = await this.github.PullRequest.Create(this.wingetRepoOwner, this.wingetRepo, newPullRequest); + + return pullRequest; + } + catch (Exception) + { + // On error, cleanup created branch/repo before re-throwing + if (createdRepo) + { + await this.github.Repository.Delete(repo.Id); + } + else if (newBranch != null) + { + await this.github.Git.Reference.Delete(repo.Id, newBranch.Ref); + } + + throw; + } + } + + private async Task DeletePullRequestBranch(int pullRequestId) + { + // Delete branch if it's not on a forked repo. + var pullRequest = await this.github.PullRequest.Get(this.wingetRepoOwner, this.wingetRepo, pullRequestId); + if (pullRequest.Base.Repository.Id == pullRequest.Head.Repository.Id) + { + string newBranchNameHeads = $"heads/{pullRequest.Head.Ref}"; + await this.github.Git.Reference.Delete(this.wingetRepoOwner, this.wingetRepo, newBranchNameHeads); + } + } + } +}