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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ Task<Result<GitInstallationInfo>> ResolveAppInstallationAsync(
string? appKey = null,
CancellationToken cancellationToken = default);

/// <summary>
/// App-installation mode only: resolves a single installation of the platform
/// app by its provider-side id. Unlike <see cref="ResolveAppInstallationAsync"/>
/// (which probes by account) and <see cref="ListAppInstallationsAsync"/> (which
/// pages the whole list), this is an O(1) point lookup — use it when a caller
/// already holds an installation id and only needs to confirm it still belongs
/// to the app. Fails with <c>Git.InstallationNotFound</c> when the id does not
/// belong to the app (never installed, or the installation was deleted).
/// </summary>
/// <param name="installationId">The provider-side installation id to resolve.</param>
/// <param name="appKey">Selects an app registration; <see langword="null"/> = default app.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task<Result<GitInstallationInfo>> ResolveAppInstallationByIdAsync(
string installationId,
string? appKey = null,
CancellationToken cancellationToken = default);

/// <summary>
/// App-installation mode only: lists every installation of the platform app
/// across all accounts. Adapters page through the provider API internally
Expand Down
10 changes: 10 additions & 0 deletions src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ public static Error AppNotInstalled(string @namespace, string? installUrl = null
metadata);
}

/// <summary>
/// The provider-side installation id does not belong to the platform app
/// (never installed, or the installation was deleted). Returned by the
/// by-id installation lookup on the credential broker.
/// </summary>
public static Error InstallationNotFound(string installationId) =>
Error.NotFound(
$"{Prefix}.InstallationNotFound",
$"Installation '{installationId}' was not found for the platform app.");

/// <summary>
/// The requested repository does not exist or is not visible to the credential.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ public Task<Result<GitInstallationInfo>> ResolveAppInstallationAsync(
string @namespace, string? appKey = null, CancellationToken cancellationToken = default)
=> NotConfiguredTask<GitInstallationInfo>();

/// <inheritdoc />
public Task<Result<GitInstallationInfo>> ResolveAppInstallationByIdAsync(
string installationId, string? appKey = null, CancellationToken cancellationToken = default)
=> NotConfiguredTask<GitInstallationInfo>();

/// <inheritdoc />
public Task<Result<IReadOnlyList<GitInstallationInfo>>> ListAppInstallationsAsync(
string? appKey = null, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,34 @@ public async Task<Result<GitInstallationInfo>> ResolveAppInstallationAsync(
return Result.Failure<GitInstallationInfo>(GitErrors.AppNotInstalled(@namespace, BuildInstallUrl(registration)));
}

/// <inheritdoc />
public async Task<Result<GitInstallationInfo>> ResolveAppInstallationByIdAsync(
string installationId, string? appKey = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(installationId);

if (_options.ResolveApp(appKey) is not { } registration)
{
return Result.Failure<GitInstallationInfo>(GitErrors.NotConfigured(GitHubDefaults.Provider));
}

var jwt = _tokenService.CreateAppJwt(registration);
if (jwt.IsFailure)
{
return Result.Failure<GitInstallationInfo>(jwt.Error);
}

var apiBase = GitHubDefaults.EnsureTrailingSlash(_options.ApiBaseUrl);
var installation = await _rest.GetAsync<GitHubInstallationDto>(
apiBase, jwt.Value,
$"app/installations/{Uri.EscapeDataString(installationId)}",
GitRestErrorContext.ForInstallation(installationId), cancellationToken).ConfigureAwait(false);

return installation.IsFailure
? Result.Failure<GitInstallationInfo>(installation.Error)
: Result.Success(installation.Value.ToInstallationInfo());
}

/// <inheritdoc />
public async Task<Result<IReadOnlyList<GitInstallationInfo>>> ListAppInstallationsAsync(
string? appKey = null, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public static Error FromStatus(
return GitErrors.NamespaceNotFound(ns);
}

if (context.InstallationId is { } installationId)
{
return GitErrors.InstallationNotFound(installationId);
}

return GitErrors.ProviderRejected(Provider, statusCode, detail);

case 409:
Expand Down Expand Up @@ -94,6 +99,11 @@ public static Error FromException(Exception exception, GitRestErrorContext conte
return GitErrors.NamespaceNotFound(ns);
}

if (context.InstallationId is { } installationId)
{
return GitErrors.InstallationNotFound(installationId);
}

return GitErrors.ProviderRejected(Provider, 404, exception.Message);

case ApiValidationException validation when LooksLikeConflict(validation.Message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ internal sealed record GitRestErrorContext
/// <summary>Gets the namespace this operation targeted; a 404 maps to <c>Git.NamespaceNotFound</c>.</summary>
public string? Namespace { get; init; }

/// <summary>Gets the installation id this operation targeted; a 404 maps to <c>Git.InstallationNotFound</c>.</summary>
public string? InstallationId { get; init; }

/// <summary>Gets the resource name a conflict (409/422 "already exists") refers to.</summary>
public string? ConflictResource { get; init; }

Expand All @@ -33,4 +36,8 @@ public static GitRestErrorContext ForRepository(GitRepositoryRef repository) =>
/// <summary>Builds a context for a namespace-scoped operation.</summary>
public static GitRestErrorContext ForNamespace(string @namespace) =>
new() { Namespace = @namespace, ConflictResource = @namespace };

/// <summary>Builds a context for a lookup of a single installation by its id.</summary>
public static GitRestErrorContext ForInstallation(string installationId) =>
new() { InstallationId = installationId };
}
14 changes: 14 additions & 0 deletions src/Testing/Compendium.Testing/Git/InMemoryGitServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,20 @@ public Task<Result<GitInstallationInfo>> ResolveAppInstallationAsync(
}
}

/// <inheritdoc />
public Task<Result<GitInstallationInfo>> ResolveAppInstallationByIdAsync(
string installationId, string? appKey = null, CancellationToken cancellationToken = default)
{
Log("ResolveAppInstallationById", installationId);
lock (_gate)
{
var install = _installations.FirstOrDefault(i => i.InstallationId == installationId);
return Task.FromResult(install is null
? Result.Failure<GitInstallationInfo>(GitErrors.InstallationNotFound(installationId))
: Result.Success(install));
}
}

/// <inheritdoc />
public Task<Result<IReadOnlyList<GitInstallationInfo>>> ListAppInstallationsAsync(
string? appKey = null, CancellationToken cancellationToken = default)
Expand Down
12 changes: 12 additions & 0 deletions tests/Unit/Compendium.Abstractions.Git.Tests/GitErrorsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ public void AppNotInstalled_WithInstallUrl_CarriesTheInstallUrlMetadata()
error.Metadata.Should().ContainKey("installUrl").WhoseValue.Should().Be("https://example.invalid/install");
}

[Fact]
public void InstallationNotFound_MapsToNotFound()
{
// Arrange / Act
var error = GitErrors.InstallationNotFound("555");

// Assert
error.Code.Should().Be("Git.InstallationNotFound");
error.Type.Should().Be(ErrorType.NotFound);
error.Message.Should().Be("Installation '555' was not found for the platform app.");
}

[Fact]
public void RepositoryNotFound_MapsToNotFound()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,31 @@ public async Task ResolveAppInstallation_WhenSeeded_ReturnsInstallation()
result.Value.InstallationId.Should().Be("inst-1");
}

[Fact]
public async Task ResolveAppInstallationById_WhenSeeded_ReturnsInstallation()
{
// Arrange
_server.SeedInstallation(new GitInstallationInfo("inst-1", "acme", GitAccountType.Organization));

// Act
var result = await _server.Credentials.ResolveAppInstallationByIdAsync("inst-1");

// Assert
result.IsSuccess.Should().BeTrue();
result.Value.AccountLogin.Should().Be("acme");
}

[Fact]
public async Task ResolveAppInstallationById_WhenAbsent_FailsInstallationNotFound()
{
// Arrange / Act
var result = await _server.Credentials.ResolveAppInstallationByIdAsync("missing");

// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Git.InstallationNotFound");
}

[Fact]
public async Task ListAppInstallations_ReturnsSeededInstallations()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public async Task Credentials_EveryMethod_ReturnsNotConfigured()
AssertNotConfigured(await broker.MintAsync(Connection, new GitAccessTokenScope()));
AssertNotConfigured(await broker.ValidateAsync(Connection));
AssertNotConfigured(await broker.ResolveAppInstallationAsync("acme"));
AssertNotConfigured(await broker.ResolveAppInstallationByIdAsync("inst-1"));
AssertNotConfigured(await broker.ListAppInstallationsAsync());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,63 @@ public async Task ResolveAppInstallation_NotInstalled_ReturnsAnInstallUrl()
result.Error.Metadata["installUrl"].Should().Be("https://github.com/apps/compendium-app/installations/new");
}

[Fact]
public async Task ResolveAppInstallationById_FindsTheInstallation()
{
using var harness = new GitHubTestHarness();
harness.Server.Given(Request.Create().WithPath("/app/installations/555").UsingGet())
.RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new
{
id = 555,
account = new { login = "acme", type = "Organization" },
suspended_at = "2026-01-02T03:04:05Z",
}));

var result = await harness.Broker.ResolveAppInstallationByIdAsync("555");

result.IsSuccess.Should().BeTrue();
result.Value.InstallationId.Should().Be("555");
result.Value.AccountLogin.Should().Be("acme");
result.Value.AccountType.Should().Be(GitAccountType.Organization);
result.Value.Suspended.Should().BeTrue();
}

[Fact]
public async Task ResolveAppInstallationById_MapsAUserAccount_AndReportsNotSuspended()
{
using var harness = new GitHubTestHarness();
harness.Server.Given(Request.Create().WithPath("/app/installations/42").UsingGet())
.RespondWith(Response.Create().WithStatusCode(200)
.WithBodyAsJson(new { id = 42, account = new { login = "octocat", type = "User" } }));

var result = await harness.Broker.ResolveAppInstallationByIdAsync("42");

result.Value.AccountType.Should().Be(GitAccountType.User);
result.Value.Suspended.Should().BeFalse();
}

[Fact]
public async Task ResolveAppInstallationById_WhenUnknown_FailsInstallationNotFound()
{
using var harness = new GitHubTestHarness();
harness.Server.Given(Request.Create().WithPath("/app/installations/999").UsingGet())
.RespondWith(Response.Create().WithStatusCode(404));

var result = await harness.Broker.ResolveAppInstallationByIdAsync("999");

result.Error.Code.Should().Be("Git.InstallationNotFound");
result.Error.Type.Should().Be(ErrorType.NotFound);
}

[Fact]
public async Task ResolveAppInstallationById_UnknownAppKey_FailsNotConfigured()
{
using var harness = new GitHubTestHarness();

(await harness.Broker.ResolveAppInstallationByIdAsync("555", "missing"))
.Error.Code.Should().Be("Git.NotConfigured");
}

[Fact]
public async Task ListAppInstallations_PagesThroughEveryInstallation()
{
Expand Down
Loading