feat: implement delete repos command
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user