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

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

View File

@@ -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);
}
}
}

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>
<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" />