feat: implement delete repos command

This commit is contained in:
2025-09-17 23:30:05 +02:00
parent 11a35ec5f3
commit 9d6a99da67
8 changed files with 205 additions and 51 deletions

View File

@@ -7,7 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GithubRepoRemover", "src\GithubRepoRemover\GithubRepoRemover.csproj", "{2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GithubRepoRemover", "src\GithubRepoRemover\GithubRepoRemover.csproj", "{2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x64.Build.0 = Release|Any CPU
{2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{2B5B700E-3B8B-480E-AF0C-11D13ECCE4B4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {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 EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,9 +1,48 @@
using Refit; using Refit;
using System.Text.Json.Serialization;
namespace GithubRepoRemover.Api; namespace GithubRepoRemover.Api;
internal interface IGithubClient internal interface IGithubClient
{ {
[Get("/user/repos")] [Get("/user/repos")]
[Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", "User-Agent: Github-Repo-Remover")] [Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28", $"User-Agent: {Constants.USER_AGENT_STRING}")]
Task ListRepositoriesAsync([Authorize("Bearer")] string accessToken); 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
);

View File

@@ -1,10 +1,14 @@
using Spectre.Console; using GithubRepoRemover.Api;
using Spectre.Console;
using Spectre.Console.Cli; using Spectre.Console.Cli;
using System.ComponentModel; using System.ComponentModel;
namespace GithubRepoRemover.Command; 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 public class Settings : CommandSettings
{ {
[CommandOption("-t|--token")] [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);
}
} }
} }

View File

@@ -0,0 +1,5 @@
namespace GithubRepoRemover;
internal static class Constants
{
internal const string USER_AGENT_STRING = $"Github-Repo-Remover {ThisAssembly.AssemblyInformationalVersion}";
}

View File

@@ -9,6 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" /> <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" Version="8.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" /> <PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageReference Include="Spectre.Console" Version="0.51.1" /> <PackageReference Include="Spectre.Console" Version="0.51.1" />

View File

@@ -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>

View File

@@ -1,10 +0,0 @@
namespace GithubRepoRemover.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

4
version.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.0"
}