diff --git a/GithubRepoRemover.sln b/GithubRepoRemover.sln index 713731b..e59d276 100644 --- a/GithubRepoRemover.sln +++ b/GithubRepoRemover.sln @@ -7,7 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GithubRepoRemover", "src\GithubRepoRemover\GithubRepoRemover.csproj", "{2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GithubRepoRemover.Tests", "test\GithubRepoRemover.Tests\GithubRepoRemover.Tests.csproj", "{2DD77BB6-3255-445C-9B53-349A8925DC24}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + version.json = version.json + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -31,24 +34,14 @@ Global {2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}.Release|x64.Build.0 = Release|Any CPU {2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}.Release|x86.ActiveCfg = Release|Any CPU {2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}.Release|x86.Build.0 = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|x64.ActiveCfg = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|x64.Build.0 = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|x86.ActiveCfg = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Debug|x86.Build.0 = Debug|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|Any CPU.Build.0 = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|x64.ActiveCfg = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|x64.Build.0 = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|x86.ActiveCfg = Release|Any CPU - {2DD77BB6-3255-445C-9B53-349A8925DC24}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {2DD77BB6-3255-445C-9B53-349A8925DC24} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {81026922-E0EA-40FF-95C8-5D4C1E82F5B2} EndGlobalSection EndGlobal diff --git a/src/GithubRepoRemover/Api/IGithubClient.cs b/src/GithubRepoRemover/Api/IGithubClient.cs index 9792598..41eed05 100644 --- a/src/GithubRepoRemover/Api/IGithubClient.cs +++ b/src/GithubRepoRemover/Api/IGithubClient.cs @@ -1,9 +1,48 @@ using Refit; +using System.Text.Json.Serialization; namespace GithubRepoRemover.Api; internal interface IGithubClient { [Get("/user/repos")] - [Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", "User-Agent: Github-Repo-Remover")] - Task ListRepositoriesAsync([Authorize("Bearer")] string accessToken); + [Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", $"User-Agent: {Constants.USER_AGENT_STRING}")] + internal Task>> ListRepositoriesAsync_Internal([Authorize("Bearer")] string accessToken, + [AliasAs("affiliation")][Query] string affiliation = "owner", + [AliasAs("per_page")][Query] int perPage = 100, + [AliasAs("page")][Query] int page = 1); + + public async Task<(bool hasMore, IReadOnlyCollection)> ListRepositoriesAsync(string accessToken, + int perPage = 100, + int page = 1) + { + var response = await ListRepositoriesAsync_Internal(accessToken, perPage: perPage, page: page); + await response.EnsureSuccessStatusCodeAsync(); + + bool hasMoreContent = response.Headers.Any(x => x.Key == "link") + && response.Headers + .First(x => x.Key == "link") + .Value.Any(x => x.Contains("rel=\"next\"", StringComparison.InvariantCultureIgnoreCase)); + + return (hasMoreContent, response.Content ?? []); + } + + [Get("/user")] + [Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", $"User-Agent: {Constants.USER_AGENT_STRING}")] + Task GetAuthenticatedUserAsync([Authorize("Bearer")] string accessToken); + + [Delete("/repos/{owner}/{repo}")] + [Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", $"User-Agent: {Constants.USER_AGENT_STRING}")] + Task DeleteRepositoryAsync([Authorize("Bearer")] string accessToken, + string owner, + string repo); } + +public record GithubRepositoryResponse( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("full_name")] string FullName, + [property: JsonPropertyName("name")] string ShortName +); + +public record GithubAuthenticatedUserResponse( + [property: JsonPropertyName("login")] string Name +); \ No newline at end of file diff --git a/src/GithubRepoRemover/Command/DeleteReposCommand.cs b/src/GithubRepoRemover/Command/DeleteReposCommand.cs index 8ea4a0d..9c21000 100644 --- a/src/GithubRepoRemover/Command/DeleteReposCommand.cs +++ b/src/GithubRepoRemover/Command/DeleteReposCommand.cs @@ -1,10 +1,14 @@ -using Spectre.Console; +using GithubRepoRemover.Api; +using Spectre.Console; using Spectre.Console.Cli; using System.ComponentModel; namespace GithubRepoRemover.Command; -internal class DeleteReposCommand : Command +internal class DeleteReposCommand(IGithubClient githubClient) : AsyncCommand { + private readonly IGithubClient _githubClient = githubClient; + + public class Settings : CommandSettings { [CommandOption("-t|--token")] @@ -19,8 +23,144 @@ internal class DeleteReposCommand : Command } } - public override int Execute(CommandContext context, Settings settings) + public override async Task ExecuteAsync(CommandContext context, Settings settings) { - throw new NotImplementedException(); + AnsiConsole.Write( + new FigletText($"GitHub Repo Remover v{ThisAssembly.AssemblyFileVersion}") + .Centered() + .Color(Color.Red) + ); + + var authenticatedUser = await AnsiConsole.Status() + .Spinner(Spinner.Known.Aesthetic) + .StartAsync("Reading logged in user...", async ctx => + { + return await ExecuteGithubCallWithErrorHandling(() => _githubClient.GetAuthenticatedUserAsync(settings.Token)); + }); + + var results = await AnsiConsole.Status() + .Spinner(Spinner.Known.Aesthetic) + .StartAsync("Reading your repositories...", async ctx => + { + return await GetAllRepositoriesAsync(settings.Token); + }); + + var selectedRepoNames = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title($"Select [red]repositories to delete[/] for user [bold]{authenticatedUser.Name}[/]:") + .Required() + .PageSize(10) + .MoreChoicesText("[grey](Move up and down to reveal more)[/]") + .InstructionsText( + "[grey](Press [blue][/] to toggle a repository, " + + "[green][/] to accept[/], " + + "[red]ctrl + c[/] to quit" + + ")" + ) + .WrapAround(true) + .AddChoices(results.Select(x => x.FullName)) + ); + + if (selectedRepoNames.Count < 1) return 0; + + AnsiConsole.WriteLine("You selected the following repositories:"); + Table tbl = new(); + tbl.AddColumn("Name"); + + List selectedRepos = []; + foreach (string repositoryName in selectedRepoNames) + { + var repoData = results.First(x => x.FullName == repositoryName); + selectedRepos.Add(repoData); + tbl.AddRow(repoData.FullName); + } + + AnsiConsole.Write(tbl); + + bool confirmation = AnsiConsole.Prompt( + new TextPrompt("These repositories will be [bold red]permanently deleted[/]. Are you sure you want to continue?") + .AddChoice(true) + .AddChoice(false) + .DefaultValue(false) + .WithConverter(choice => choice ? "y" : "n") + ); + + if (!confirmation) return 0; + + confirmation = AnsiConsole.Prompt( + new TextPrompt("[bold]Are you sure?[/]") + .AddChoice(true) + .AddChoice(false) + .DefaultValue(false) + .WithConverter(choice => choice ? "y" : "n") + ); + + if (!confirmation) return 0; + + int currentItem = 1; + int maxItems = selectedRepos.Count; + foreach (var selectedRepo in selectedRepos) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Aesthetic) + .StartAsync($"[[{currentItem++} of {maxItems}]] Deleting [bold]{selectedRepo.FullName}[/]...", async ctx => + { + _ = await ExecuteGithubCallWithErrorHandling(async () => + { + await _githubClient.DeleteRepositoryAsync(settings.Token, authenticatedUser.Name, selectedRepo.ShortName); + return true; + }); + await Task.Delay(275); + }); + } + + AnsiConsole.MarkupLineInterpolated($"[bold green]Successfully deleted {maxItems} repositories![/]"); + + return 0; + } + + private async Task> GetAllRepositoriesAsync(string token) + { + List results = []; + bool hasMore = true; + int page = 1; + while (hasMore) + { + var (hasMoreReq, pageData) = await ExecuteGithubCallWithErrorHandling(() => _githubClient.ListRepositoriesAsync(token, page: page++)); + + hasMore = hasMoreReq; + results.AddRange(pageData); + + if (hasMore) + { + await Task.Delay(500); + } + } + return [.. results.OrderBy(x => x.FullName)]; + } + + private static async Task ExecuteGithubCallWithErrorHandling(Func> func) + { + try + { + return await func(); + } + catch (Refit.ApiException apiEx) + { + if (apiEx.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception($"Error while calling GitHub API; Access forbidden", apiEx); + } + else if (apiEx.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + throw new Exception("Error while calling GitHub API; GitHub rate limit exceeded. Try again later", apiEx); + } + + throw new Exception($"Error while calling GitHub API; Response status does not indicate success: {apiEx.StatusCode} {apiEx.ReasonPhrase}", apiEx); + } + catch (Exception ex) + { + throw new Exception($"Error while calling GitHub API; Unhandled exception. {ex.Message}", ex); + } } } diff --git a/src/GithubRepoRemover/Constants.cs b/src/GithubRepoRemover/Constants.cs new file mode 100644 index 0000000..d57c874 --- /dev/null +++ b/src/GithubRepoRemover/Constants.cs @@ -0,0 +1,5 @@ +namespace GithubRepoRemover; +internal static class Constants +{ + internal const string USER_AGENT_STRING = $"Github-Repo-Remover {ThisAssembly.AssemblyInformationalVersion}"; +} diff --git a/src/GithubRepoRemover/GithubRepoRemover.csproj b/src/GithubRepoRemover/GithubRepoRemover.csproj index b57b5da..21d6de8 100644 --- a/src/GithubRepoRemover/GithubRepoRemover.csproj +++ b/src/GithubRepoRemover/GithubRepoRemover.csproj @@ -9,6 +9,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/GithubRepoRemover.Tests/GithubRepoRemover.Tests.csproj b/test/GithubRepoRemover.Tests/GithubRepoRemover.Tests.csproj deleted file mode 100644 index d7f0b2e..0000000 --- a/test/GithubRepoRemover.Tests/GithubRepoRemover.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net9.0 - enable - enable - false - - - - - - - - - - - - - - diff --git a/test/GithubRepoRemover.Tests/UnitTest1.cs b/test/GithubRepoRemover.Tests/UnitTest1.cs deleted file mode 100644 index 94f1e75..0000000 --- a/test/GithubRepoRemover.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GithubRepoRemover.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/version.json b/version.json new file mode 100644 index 0000000..d9c0df4 --- /dev/null +++ b/version.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0" +} \ No newline at end of file