feat: implement delete repos command
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ApiResponse<IReadOnlyCollection<GithubRepositoryResponse>>> 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<GithubRepositoryResponse>)> 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<GithubAuthenticatedUserResponse> 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
|
||||
);
|
||||
@@ -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<DeleteReposCommand.Settings>
|
||||
internal class DeleteReposCommand(IGithubClient githubClient) : AsyncCommand<DeleteReposCommand.Settings>
|
||||
{
|
||||
private readonly IGithubClient _githubClient = githubClient;
|
||||
|
||||
|
||||
public class Settings : CommandSettings
|
||||
{
|
||||
[CommandOption("-t|--token")]
|
||||
@@ -19,8 +23,144 @@ internal class DeleteReposCommand : Command<DeleteReposCommand.Settings>
|
||||
}
|
||||
}
|
||||
|
||||
public override int Execute(CommandContext context, Settings settings)
|
||||
public override async Task<int> 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<string>()
|
||||
.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]<space>[/] to toggle a repository, " +
|
||||
"[green]<enter>[/] 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<GithubRepositoryResponse> 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<bool>("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<bool>("[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<IReadOnlyCollection<GithubRepositoryResponse>> GetAllRepositoriesAsync(string token)
|
||||
{
|
||||
List<GithubRepositoryResponse> 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<TResult> ExecuteGithubCallWithErrorHandling<TResult>(Func<Task<TResult>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src/GithubRepoRemover/Constants.cs
Normal file
5
src/GithubRepoRemover/Constants.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace GithubRepoRemover;
|
||||
internal static class Constants
|
||||
{
|
||||
internal const string USER_AGENT_STRING = $"Github-Repo-Remover {ThisAssembly.AssemblyInformationalVersion}";
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Refit" Version="8.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.51.1" />
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace GithubRepoRemover.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
4
version.json
Normal file
4
version.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
|
||||
"version": "1.0"
|
||||
}
|
||||
Reference in New Issue
Block a user