Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal sealed class OrgRemoveCommand : ICliCommand
public Command Build() =>
new("remove", "Remove an organization")
{
new Argument<string>("name") { Description = "Organization name" }
new Argument<string>("name") { Description = "Organization name" },
new Option<bool>("--yes", "-y") { Description = "Confirm removal without prompting" }
};
}
}
7 changes: 4 additions & 3 deletions src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
/// <item>Child command of <see cref="WorkCommand"/>.</item>
/// <item>Execution handled by <see cref="WorkRemoveCommandHandler"/>.</item>
/// <item>Requires workspace name argument.</item>
/// <item>Optionally specifies the organization using <c>--org</c> option.</item>
/// <item>Requires the organization using <c>--org</c> option.</item>
/// <item>Registered automatically as singleton via <see cref="AutoRegisterAttribute"/>.</item>
/// </list>
/// </remarks>
Expand All @@ -40,6 +40,7 @@ public Command Build() =>
new("remove", "Remove workspace")
{
new Argument<string>("name") { Description = "Workspace name" },
new Option<string>("--org") { Description = "Organization name" }
new Option<string>("--org") { Description = "Organization name", Required = true },
new Option<bool>("--yes", "-y") { Description = "Confirm removal without prompting" }
};
}
}
49 changes: 30 additions & 19 deletions src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using System.CommandLine;
using ChangeTrace.Cli.Interfaces;
using ChangeTrace.Cli.Prompts;
using ChangeTrace.Configuration.Discovery;
using ChangeTrace.CredentialTrace.Interfaces;
using ChangeTrace.CredentialTrace.Profiles;
using ChangeTrace.CredentialTrace.Services;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;

Expand All @@ -23,7 +23,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;
[AutoRegister(ServiceLifetime.Transient, typeof(OrgCreateCommandHandler))]
internal sealed class OrgCreateCommandHandler(
IAuthService auth,
IProfileStore<OrganizationProfile> store) : ICliHandler
IProfileStore<OrganizationProfile> store,
IEnumerable<IAuthProvider> providers) : ICliHandler
{
/// <summary>
/// Executes 'organization create' command asynchronously.
Expand All @@ -42,10 +43,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
}

if (string.IsNullOrWhiteSpace(provider))
{
AnsiConsole.MarkupLine("[red]Error:[/] --provider is required.");
provider = ProviderPrompt.SelectProvider(providers);

if (string.IsNullOrWhiteSpace(provider))
return;
}

try
{
Expand All @@ -54,19 +55,7 @@ await AnsiConsole.Status()
.SpinnerStyle(Style.Parse("blue"))
.StartAsync($"Creating organization [bold]{name}[/]...", async ctx =>
{
var session = await auth.FetchSession(provider, ct);

var profile = new OrganizationProfile
{
Id = Ulid.NewUlid(),
Name = name,
Provider = provider,
CreatedAt = DateTime.UtcNow,
SessionId = session.Id
};

await store.SaveAsync(profile, ct);

await CreateOrganizationAsync(name, provider, ct);
ctx.Status("Finalizing...");
await Task.Delay(150, ct);
});
Expand All @@ -79,6 +68,28 @@ await AnsiConsole.Status()
}
}

/// <summary>
/// Creates and stores organization profile for provider.
/// </summary>
private async Task CreateOrganizationAsync(
string name,
string provider,
CancellationToken ct)
{
var session = await auth.FetchSession(provider, ct);

var profile = new OrganizationProfile
{
Id = Ulid.NewUlid(),
Name = name,
Provider = provider,
CreatedAt = DateTime.UtcNow,
SessionId = session.Id
};

await store.SaveAsync(profile, ct);
}

private static void DisplayConfirmation(string name, string provider)
{
var panel = new Panel(
Expand All @@ -92,4 +103,4 @@ private static void DisplayConfirmation(string name, string provider)

AnsiConsole.Write(panel);
}
}
}
46 changes: 30 additions & 16 deletions src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.CommandLine;
using ChangeTrace.Cli.Interfaces;
using ChangeTrace.Cli.Prompts;
using ChangeTrace.Configuration.Discovery;
using ChangeTrace.CredentialTrace.Interfaces;
using ChangeTrace.CredentialTrace.Profiles;
Expand All @@ -23,7 +24,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;
/// </remarks>
[AutoRegister(ServiceLifetime.Transient, typeof(OrgListCommandHandler))]
internal sealed class OrgListCommandHandler(
IProfileStore<OrganizationProfile> store) : ICliHandler
IProfileStore<OrganizationProfile> store,
IEnumerable<IAuthProvider> providers) : ICliHandler
{
/// <summary>
/// Executes 'org list' command asynchronously.
Expand All @@ -35,10 +37,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
var provider = parseResult.GetValue<string>("--provider");

if (string.IsNullOrWhiteSpace(provider))
{
AnsiConsole.MarkupLine("[red]Error:[/] --provider is required.");
provider = ProviderPrompt.SelectProvider(providers);

if (string.IsNullOrWhiteSpace(provider))
return;
}

try
{
Expand All @@ -47,25 +49,15 @@ await AnsiConsole.Status()
.SpinnerStyle(Style.Parse("blue"))
.StartAsync("Loading organizations...", async _ =>
{
var filtered = (await store.GetAllAsync(ct))
.Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))
.ToList();
var filtered = await GetOrganizationsAsync(provider, ct);

if (!filtered.Any())
{
AnsiConsole.MarkupLine("[yellow]No organizations found.[/]");
return;
}

var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Name");
table.AddColumn("Provider");

foreach (var org in filtered)
table.AddRow(org.Name, org.Provider);

AnsiConsole.Write(table);

DisplayOrganizations(filtered);
DisplayConfirmation(filtered.Count, provider);
});
}
Expand All @@ -75,6 +67,28 @@ await AnsiConsole.Status()
}
}

/// <summary>
/// Gets organizations matching provider.
/// </summary>
private async Task<List<OrganizationProfile>> GetOrganizationsAsync(
string provider,
CancellationToken ct) => [.. (await store.GetAllAsync(ct)).Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))];

/// <summary>
/// Displays organizations table.
/// </summary>
private static void DisplayOrganizations(IEnumerable<OrganizationProfile> organizations)
{
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Name");
table.AddColumn("Provider");

foreach (var org in organizations)
table.AddRow(org.Name, org.Provider);

AnsiConsole.Write(table);
}

/// <summary>
/// Displays a confirmation panel with the number of organizations found for a given provider.
/// </summary>
Expand Down
46 changes: 41 additions & 5 deletions src/Cli/Handlers/Profiles/Organizations/OrgRemoveCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,59 @@
using System.CommandLine;
using ChangeTrace.Cli.Interfaces;
using ChangeTrace.Configuration.Discovery;
using ChangeTrace.CredentialTrace.Interfaces;
using ChangeTrace.CredentialTrace.Profiles;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;

namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;

[AutoRegister(ServiceLifetime.Transient, typeof(OrgRemoveCommandHandler))]
internal sealed class OrgRemoveCommandHandler : ICliHandler
internal sealed class OrgRemoveCommandHandler(
IProfileStore<OrganizationProfile> orgStore,
IWorkspaceStore workspaceStore,
IWorkspaceContext workspaceContext) : ICliHandler
{
public Task HandleAsync(ParseResult parseResult, CancellationToken ct)
public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
{
var name = parseResult.GetValue<string>("name");
var assumeYes = parseResult.GetValue<bool>("--yes");

if (string.IsNullOrWhiteSpace(name))
{
AnsiConsole.MarkupLine("[red]Error:[/] Organization name is required.");
return Task.CompletedTask;
return;
}

var org = await orgStore.GetByNameAsync(name, ct);
if (org == null)
{
AnsiConsole.MarkupLine($"[red]Organization '{name}' not found.[/]");
return;
}

var workspaces = (await workspaceStore.GetByNameOrganization(org.Name, ct)).ToList();
if (workspaces.Any(w => workspaceContext.Current?.Id == w.Id))
{
AnsiConsole.MarkupLine("[red]Cannot remove an organization that contains the active workspace. Select another workspace first.[/]");
return;
}

return null;
var workspaceSummary = workspaces.Count == 0
? "no workspaces"
: $"{workspaces.Count} workspace(s)";

if (!assumeYes && !AnsiConsole.Confirm($"Remove organization [yellow]{org.Name}[/] and {workspaceSummary}?"))
{
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
return;
}

foreach (var workspace in workspaces)
await workspaceStore.DeleteAsync(workspace.Id, ct);

await orgStore.DeleteAsync(org.Id, ct);

AnsiConsole.MarkupLine($"[green]Removed organization '{org.Name}' and {workspaces.Count} workspace(s).[/]");
}
}
}
53 changes: 50 additions & 3 deletions src/Cli/Handlers/Profiles/Workspaces/WorkRemoveCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,62 @@
using System.CommandLine;
using ChangeTrace.Cli.Interfaces;
using ChangeTrace.Configuration.Discovery;
using ChangeTrace.CredentialTrace.Interfaces;
using ChangeTrace.CredentialTrace.Profiles;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;

namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces;

[AutoRegister(ServiceLifetime.Transient, typeof(WorkRemoveCommandHandler))]
internal sealed class WorkRemoveCommandHandler : ICliHandler
internal sealed class WorkRemoveCommandHandler(
IProfileStore<OrganizationProfile> orgStore,
IWorkspaceStore workspaceStore,
IWorkspaceContext workspaceContext) : ICliHandler
{
public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
=> await HandleAsync(
parseResult.GetValue<string>("name")!,
parseResult.GetValue<string>("--org")!,
parseResult.GetValue<bool>("--yes"),
ct);

private async Task HandleAsync(
string name,
string orgName,
bool assumeYes,
CancellationToken ct)
{

var org = await orgStore.GetByNameAsync(orgName, ct);
if (org is null)
{
AnsiConsole.MarkupLine($"[red]Organization '{orgName}' not found.[/]");
return;
}

var workspaces = await workspaceStore.GetByNameOrganization(orgName, ct);
var workspace = workspaces.FirstOrDefault(w =>
w.Name.Equals(name, StringComparison.OrdinalIgnoreCase));

if (workspace is null)
{
AnsiConsole.MarkupLine($"[red]Workspace '{name}' not found in organization '{orgName}'.[/]");
return;
}

if (workspaceContext.Current?.Id == workspace.Id)
{
AnsiConsole.MarkupLine("[red]Cannot remove the active workspace. Select another workspace first.[/]");
return;
}

if (!assumeYes && !AnsiConsole.Confirm($"Remove workspace [yellow]{workspace.Name}[/] from [yellow]{org.Name}[/]?"))
{
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
return;
}

await workspaceStore.DeleteAsync(workspace.Id, ct);
AnsiConsole.MarkupLine($"[green]Removed workspace '{workspace.Name}' from organization '{org.Name}'.[/]");
}
}
}
35 changes: 35 additions & 0 deletions src/Cli/Prompts/ProviderPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using ChangeTrace.CredentialTrace.Interfaces;
using Spectre.Console;

namespace ChangeTrace.Cli.Prompts;

/// <summary>
/// Prompts for authentication provider selection.
/// </summary>
internal static class ProviderPrompt
{
/// <summary>
/// Selects provider from registered authentication providers.
/// </summary>
public static string? SelectProvider(IEnumerable<IAuthProvider> providers)
{
var choices = providers
.Select(p => p.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToArray();

if (choices.Length == 0)
{
AnsiConsole.MarkupLine("[red]No authentication providers are registered.[/]");
return null;
}

return AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select authentication provider")
.PageSize(8)
.AddChoices(choices));
}
}
Loading