From a9083ee50cbc24e821c70276074c452136c86560 Mon Sep 17 00:00:00 2001 From: sacha Date: Fri, 24 Jul 2026 10:28:04 +0200 Subject: [PATCH 1/4] feat(git): add Compendium.Abstractions.Git + InMemoryGitServer and adapter contract kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider-agnostic git-server vertical: IGitServer facade with concern-scoped ports (credentials, repositories, pipelines, CI configuration, environments, branch policies, access control, webhooks in/out, optional namespace provisioning), GitConnection/GitCredential closed union (redacted ToString), declarative GitServerCapabilities with EnsureSupported -> Git.CapabilityNotSupported (Result failure, never an exception), neutral GitWebhookEvent union, and a fail-fast NullGitServer stub. Compendium.Testing gains Git/InMemoryGitServer (full-fidelity, thread-safe fake with seed/CompleteRun/call-log hooks; secrets stored as names only) and GitServerContractTests, the capability-honest behavioral contract every adapter must pass — InMemoryGitServer is its first subscriber (12 green, 1 skip). Claude-Session: https://claude.ai/code/session_01VFDn7gCH7vdvT4eoNhsMLr --- Compendium.sln | 14 + .../AccessControl/IGitAccessControlService.cs | 124 ++ .../Capabilities/GitCapability.cs | 74 ++ .../Capabilities/GitServerCapabilities.cs | 75 ++ .../IGitCiConfigurationService.cs | 95 ++ .../Compendium.Abstractions.Git.csproj | 18 + .../Connections/GitAccessToken.cs | 61 + .../Connections/GitAccountType.cs | 20 + .../Connections/GitConnection.cs | 34 + .../Connections/GitCredential.cs | 66 ++ .../Connections/IGitCredentialBroker.cs | 85 ++ .../Environments/IGitEnvironmentService.cs | 64 + .../Compendium.Abstractions.Git/GitErrors.cs | 144 +++ .../GlobalUsings.cs | 8 + .../Compendium.Abstractions.Git/IGitServer.cs | 75 ++ .../Pipelines/IGitPipelineService.cs | 148 +++ .../Protection/IGitBranchPolicyService.cs | 89 ++ .../Provisioning/IGitNamespaceProvisioner.cs | 55 + .../Compendium.Abstractions.Git/README.md | 31 + .../Repositories/GitRepositoryModels.cs | 116 ++ .../Repositories/IGitRepositoryService.cs | 109 ++ .../Stubs/NullGitServer.cs | 293 +++++ .../Webhooks/IGitWebhookIngestor.cs | 148 +++ .../Webhooks/IGitWebhookService.cs | 108 ++ .../Compendium.Testing.csproj | 6 + .../Git/GitServerContractTests.cs | 283 +++++ .../Git/InMemoryGitServer.cs | 1049 +++++++++++++++++ .../Compendium.Abstractions.Git.Tests.csproj | 35 + .../GlobalUsings.cs | 10 + .../InMemoryGitServerContractTests.cs | 65 + 30 files changed, 3502 insertions(+) create mode 100644 src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/README.md create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs create mode 100644 src/Testing/Compendium.Testing/Git/GitServerContractTests.cs create mode 100644 src/Testing/Compendium.Testing/Git/InMemoryGitServer.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/Compendium.Abstractions.Git.Tests.csproj create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GlobalUsings.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerContractTests.cs diff --git a/Compendium.sln b/Compendium.sln index ffc591c..17a5767 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -183,6 +183,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Cac EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching.Tests", "tests\Unit\Compendium.Abstractions.Caching.Tests\Compendium.Abstractions.Caching.Tests.csproj", "{262B8E91-B53C-49BC-833D-739DD4CFC2E1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git", "src\Abstractions\Compendium.Abstractions.Git\Compendium.Abstractions.Git.csproj", "{25E575EB-5677-4BEE-A92A-D8832C27F87A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git.Tests", "tests\Unit\Compendium.Abstractions.Git.Tests\Compendium.Abstractions.Git.Tests.csproj", "{C0C1EAC1-5C33-4451-8CD6-2521A8D13557}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -472,6 +476,14 @@ Global {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.Build.0 = Debug|Any CPU {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.ActiveCfg = Release|Any CPU {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.Build.0 = Release|Any CPU + {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Release|Any CPU.Build.0 = Release|Any CPU + {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6} @@ -557,5 +569,7 @@ Global {585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} {4A6496B8-5833-428F-92D7-3EB331F5CA32} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} {262B8E91-B53C-49BC-833D-739DD4CFC2E1} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} + {25E575EB-5677-4BEE-A92A-D8832C27F87A} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} + {C0C1EAC1-5C33-4451-8CD6-2521A8D13557} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} EndGlobalSection EndGlobal diff --git a/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs b/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs new file mode 100644 index 0000000..58f279b --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs @@ -0,0 +1,124 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.AccessControl; + +/// +/// Teams and repository access management on a namespace. Only meaningful for +/// organization namespaces — adapters fail with Git.CapabilityNotSupported +/// on user accounts. Requires +/// . +/// +public interface IGitAccessControlService +{ + /// + /// Creates the team when absent, updates it otherwise (idempotent). + /// + Task> EnsureTeamAsync( + GitConnection connection, + string @namespace, + EnsureGitTeam request, + CancellationToken cancellationToken = default); + + /// + /// Adds a user to a team (or updates their role in it). + /// + Task AddTeamMemberAsync( + GitConnection connection, + string @namespace, + string teamSlug, + string username, + GitTeamRole role, + CancellationToken cancellationToken = default); + + /// + /// Grants (or updates) a team's role on a repository. + /// + Task SetTeamRepositoryRoleAsync( + GitConnection connection, + string @namespace, + string teamSlug, + GitRepositoryRef repository, + GitRepositoryRole role, + CancellationToken cancellationToken = default); + + /// + /// Grants (or updates) an individual user's role on a repository. + /// + Task SetUserRepositoryRoleAsync( + GitConnection connection, + GitRepositoryRef repository, + string username, + GitRepositoryRole role, + CancellationToken cancellationToken = default); + + /// + /// Removes a user's direct access to a repository. Removing absent access + /// succeeds (idempotent). + /// + Task RemoveUserFromRepositoryAsync( + GitConnection connection, + GitRepositoryRef repository, + string username, + CancellationToken cancellationToken = default); +} + +/// +/// Request to create or update a team. +/// +public sealed record EnsureGitTeam +{ + /// Gets the team display name; the provider derives the slug from it. + public required string Name { get; init; } + + /// Gets the optional team description. + public string? Description { get; init; } +} + +/// +/// A team on a namespace. +/// +/// The provider-side team slug used in subsequent calls. +/// The team display name. +public sealed record GitTeam(string Slug, string Name); + +/// +/// A user's role inside a team. +/// +public enum GitTeamRole +{ + /// Regular team member. + Member, + + /// Team maintainer (can manage membership). + Maintainer, +} + +/// +/// A neutral repository access role. Adapters map onto their native role set +/// and document lossy mappings in their CAPABILITIES.md. +/// +public enum GitRepositoryRole +{ + /// Read-only access. + Read, + + /// Read plus issue/PR triage. + Triage, + + /// Read/write access. + Write, + + /// Write plus repository settings short of admin. + Maintain, + + /// Full administrative access. + Admin, +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs new file mode 100644 index 0000000..e83c483 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Capabilities; + +/// +/// The optional capabilities a git-server adapter may support. Adapters declare +/// their support level per capability in ; +/// operations relying on an unsupported capability fail with +/// Git.CapabilityNotSupported instead of throwing. +/// +public enum GitCapability +{ + /// Create a repository from a template/scaffold repository. + RepositoryFromTemplate, + + /// Read repository metadata, contents, commits, and branches. + RepositoryManagement, + + /// Create tags and publish releases. + TagsAndReleases, + + /// Set repository-scoped CI secrets (encrypted, write-only). + CiSecrets, + + /// Set repository-scoped CI variables (plaintext configuration). + CiVariables, + + /// Set namespace-scoped (organization/group) CI secrets and variables. + NamespaceSecrets, + + /// Trigger a CI pipeline / workflow run. + PipelineTrigger, + + /// Read CI pipeline run status and history. + PipelineStatus, + + /// Create and manage deployment environments. + DeploymentEnvironments, + + /// Set secrets scoped to a deployment environment. + EnvironmentSecrets, + + /// Apply branch protection policies (rulesets, protected branches). + BranchPolicies, + + /// Manage teams and repository access roles. + TeamsAndPermissions, + + /// Create and manage outgoing webhook subscriptions on repositories or namespaces. + WebhookManagement, + + /// Verify and parse inbound webhook deliveries into neutral events. + WebhookIngestion, + + /// Create the namespace (organization/group) itself via API. + NamespaceProvisioning, + + /// Authenticate as a platform app installed on the customer namespace (e.g. GitHub App). + AppInstallationAuth, + + /// Authenticate with a durable service-account / bot token. + ServiceAccountAuth, + + /// Authenticate with an OAuth user access token. + OAuthUserAuth, + + /// Narrow a minted credential to specific repositories/permissions at mint time. + ScopedTokenMinting, +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs new file mode 100644 index 0000000..59551f0 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs @@ -0,0 +1,75 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Capabilities; + +/// +/// The support level an adapter declares for a . +/// +public enum GitCapabilityLevel +{ + /// The capability is not available on this provider. + None, + + /// The capability is available with limitations (documented in the adapter's CAPABILITIES.md). + Partial, + + /// The capability is fully supported. + Full, +} + +/// +/// An adapter's declared support for a single capability. +/// +/// The support level. +/// +/// A short human-readable limitation note, required when +/// is or +/// for a capability the provider conceptually has but the adapter cannot reach +/// (e.g. "github.com org creation requires an enterprise-owner user token"). +/// +public sealed record GitCapabilitySupport(GitCapabilityLevel Level, string? Limitation = null); + +/// +/// The declarative capability matrix of a git-server adapter. Consumers use it +/// to drive UI affordances (hide or disable unsupported features with a reason) +/// and adapters use to fail uniformly. +/// +public sealed record GitServerCapabilities +{ + /// + /// Gets the provider identifier these capabilities describe (matches + /// ). + /// + public required string Provider { get; init; } + + /// + /// Gets the declared support per capability. Capabilities absent from the + /// dictionary are treated as . + /// + public required IReadOnlyDictionary Entries { get; init; } + + /// + /// Returns whether the capability is available at any level + /// ( or ). + /// + public bool Supports(GitCapability capability) => + Entries.TryGetValue(capability, out var support) && support.Level != GitCapabilityLevel.None; + + /// + /// Returns success when the capability is available, otherwise the standard + /// Git.CapabilityNotSupported failure. Adapters call this at the top + /// of optional-capability methods so every provider fails identically. + /// + public Result EnsureSupported(GitCapability capability) => + Supports(capability) + ? Result.Success() + : Result.Failure(GitErrors.NotSupported( + Provider, + capability, + Entries.TryGetValue(capability, out var support) ? support.Limitation : null)); +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs b/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs new file mode 100644 index 0000000..cfdbbea --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs @@ -0,0 +1,95 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.CiConfiguration; + +/// +/// CI secrets and variables management. Secrets are write-only on every +/// provider — values can be set and deleted but never read back. Adapters +/// perform any provider-required client-side encryption (GitHub: libsodium +/// sealed box) transparently. +/// +public interface IGitCiConfigurationService +{ + /// + /// Creates or updates CI secrets at . Requires + /// (repository scope), + /// (namespace scope), or + /// (environment scope). + /// + Task SetSecretsAsync( + GitConnection connection, + GitConfigurationScope scope, + IReadOnlyDictionary secrets, + CancellationToken cancellationToken = default); + + /// + /// Deletes a CI secret at . Deleting an absent + /// secret succeeds (idempotent). + /// + Task DeleteSecretAsync( + GitConnection connection, + GitConfigurationScope scope, + string name, + CancellationToken cancellationToken = default); + + /// + /// Creates or updates CI variables (plaintext configuration) at + /// . Requires + /// . + /// + Task SetVariablesAsync( + GitConnection connection, + GitConfigurationScope scope, + IReadOnlyDictionary variables, + CancellationToken cancellationToken = default); + + /// + /// Deletes a CI variable at . Deleting an absent + /// variable succeeds (idempotent). + /// + Task DeleteVariableAsync( + GitConnection connection, + GitConfigurationScope scope, + string name, + CancellationToken cancellationToken = default); +} + +/// +/// Where a CI secret or variable lives, as a closed union: on a repository, on +/// a namespace (organization/group), or on a deployment environment of a +/// repository. +/// +public abstract record GitConfigurationScope +{ + private GitConfigurationScope() + { + } + + /// + /// Repository-scoped configuration. + /// + /// The repository. + public sealed record Repository(GitRepositoryRef Ref) : GitConfigurationScope; + + /// + /// Namespace-scoped (organization/group) configuration, shared by the + /// namespace's repositories. + /// + /// The namespace login. + public sealed record Namespace(string Name) : GitConfigurationScope; + + /// + /// Environment-scoped configuration on a repository's deployment environment. + /// + /// The repository. + /// The deployment environment name. + public sealed record Environment(GitRepositoryRef Ref, string EnvironmentName) : GitConfigurationScope; +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj b/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj new file mode 100644 index 0000000..ed17128 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj @@ -0,0 +1,18 @@ + + + + Compendium.Abstractions.Git + Compendium.Abstractions.Git + Compendium.Abstractions.Git + Git-server abstractions for Compendium Framework: the IGitServer facade with concern-scoped ports (credentials, repositories, pipelines, CI configuration, deployment environments, branch policies, access control, webhooks, namespace provisioning), a provider-neutral connection/credential model, and declarative per-adapter capabilities. Provider-agnostic ports for GitHub, GitLab, Gitea/Forgejo, Azure DevOps, and other git servers. + + + + + + + + + + + diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs new file mode 100644 index 0000000..10ff0fd --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Connections; + +/// +/// A ready-to-use, short-lived token minted for a . +/// Usable against the provider's API and for git-over-HTTPS via +/// . +/// +public sealed record GitAccessToken +{ + /// + /// Gets the token material. Redacted in ToString(). + /// + public required string Token { get; init; } + + /// + /// Gets the instant the token expires. For providers whose tokens do not + /// expire (durable bot tokens passed through), adapters report a far-future + /// value and document it in CAPABILITIES.md. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Gets the username to pair with the token for HTTP basic authentication + /// on git operations (GitHub: "x-access-token"). + /// + public required string HttpBasicUsername { get; init; } + + /// + public override string ToString() => $"GitAccessToken(***, ExpiresAt={ExpiresAt:O})"; +} + +/// +/// Optional narrowing applied when minting a token (GitHub: repository_ids +/// — at most 500 — plus a permissions subset). Providers without token +/// scoping ignore the request; they declare +/// as unsupported. +/// +public sealed record GitAccessTokenScope +{ + /// + /// Gets the repositories the token should be limited to; + /// keeps the credential's full repository access. + /// + public IReadOnlyList? Repositories { get; init; } + + /// + /// Gets the permission subset to request, keyed by provider-native + /// permission names (stringly-typed in v1; see the adapter's CAPABILITIES.md + /// for the accepted keys). + /// + public IReadOnlyDictionary? Permissions { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs new file mode 100644 index 0000000..5cb1de9 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Connections; + +/// +/// The kind of account a namespace or credential identity refers to. +/// +public enum GitAccountType +{ + /// An organization / group account. + Organization, + + /// A personal user account. + User, +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs new file mode 100644 index 0000000..d95bb11 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Connections; + +/// +/// Identifies which git server to talk to and with what credential. Every port +/// method takes a connection explicitly — ports are stateless singletons and a +/// single adapter instance serves any number of tenants. +/// +public sealed record GitConnection +{ + /// + /// Gets the provider identifier (matches , + /// e.g. "github", "gitlab", "gitea"). + /// + public required string Provider { get; init; } + + /// + /// Gets the base API URL for self-hosted instances (GitLab on-prem, Gitea, + /// GitHub Enterprise Server). targets the provider's + /// cloud service. + /// + public Uri? ServerUrl { get; init; } + + /// + /// Gets the credential used for this connection. + /// + public required GitCredential Credential { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs new file mode 100644 index 0000000..919bdc8 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs @@ -0,0 +1,66 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Connections; + +/// +/// The credential material of a , as a closed union. +/// Token-bearing members redact their material in ToString() so a logged +/// connection never leaks a secret. +/// +public abstract record GitCredential +{ + private GitCredential() + { + } + + /// + /// A platform-owned app installed on the customer namespace (GitHub App + /// installation). The adapter mints short-lived tokens from the app's + /// private key; no durable secret is stored per connection. + /// + /// The provider-side installation identifier. + /// + /// Selects an app registration from the adapter options; + /// selects the default app. Per-instance apps created via a manifest flow + /// plug in here later without a contract change. + /// + public sealed record AppInstallation(string InstallationId, string? AppKey = null) : GitCredential; + + /// + /// A durable machine token (GitLab group access token, Gitea/Forgejo bot + /// PAT, Azure DevOps service principal secret). + /// + /// The token material. Redacted in ToString(). + public sealed record ServiceAccountToken(string Token) : GitCredential + { + /// + public override string ToString() => "ServiceAccountToken(***)"; + } + + /// + /// A caller-supplied user personal access token, held in memory for the + /// duration of the call only — never persisted, logged, or cached. + /// + /// The token material. Redacted in ToString(). + public sealed record PersonalAccessToken(string Token) : GitCredential + { + /// + public override string ToString() => "PersonalAccessToken(***)"; + } + + /// + /// An OAuth user access token (user-to-server), used to act as — or verify + /// the identity of — a specific human. + /// + /// The token material. Redacted in ToString(). + public sealed record OAuthAccessToken(string AccessToken) : GitCredential + { + /// + public override string ToString() => "OAuthAccessToken(***)"; + } +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs new file mode 100644 index 0000000..ba3a4f1 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Connections; + +/// +/// Mints, validates, and discovers credentials for git connections. This is the +/// auth seam of the abstraction: app-installation providers (GitHub) mint +/// ephemeral installation tokens from the platform app's private key, while +/// token-based providers (GitLab, Gitea) wrap the connection's durable token. +/// +public interface IGitCredentialBroker +{ + /// + /// Mints a short-lived, ready-to-use token for the connection, optionally + /// narrowed by . Adapters cache minted tokens per + /// credential identity and re-mint before expiry. + /// + Task> MintAsync( + GitConnection connection, + GitAccessTokenScope? scope = null, + CancellationToken cancellationToken = default); + + /// + /// Validates the connection's credential and reports the identity behind it. + /// Fails with Git.AuthenticationFailed when the credential is + /// expired, revoked, or malformed. + /// + Task> ValidateAsync( + GitConnection connection, + CancellationToken cancellationToken = default); + + /// + /// App-installation mode only: discovers the platform app's installation on + /// a namespace. Fails with Git.AppNotInstalled (metadata carries + /// installUrl) when the app is not installed there. + /// + /// The organization/group or user login to probe. + /// Selects an app registration; = default app. + /// A cancellation token. + Task> ResolveAppInstallationAsync( + string @namespace, + string? appKey = null, + CancellationToken cancellationToken = default); + + /// + /// App-installation mode only: lists every installation of the platform app + /// across all accounts. Adapters page through the provider API internally + /// and return the full list. Used by reconciliation jobs to heal missed + /// webhooks. + /// + /// Selects an app registration; = default app. + /// A cancellation token. + Task>> ListAppInstallationsAsync( + string? appKey = null, + CancellationToken cancellationToken = default); +} + +/// +/// The identity behind a validated credential. +/// +/// The login of the account the credential acts as. +/// Whether that account is an organization or a user. +/// The account's display name, when the provider reports one. +public sealed record GitConnectionIdentity( + string AccountLogin, + GitAccountType AccountType, + string? DisplayName = null); + +/// +/// A platform-app installation on a customer account. +/// +/// The provider-side installation identifier. +/// The login of the account the app is installed on. +/// Whether that account is an organization or a user. +/// True when the installation is currently suspended. +public sealed record GitInstallationInfo( + string InstallationId, + string AccountLogin, + GitAccountType AccountType, + bool Suspended = false); diff --git a/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs b/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs new file mode 100644 index 0000000..7c8a965 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs @@ -0,0 +1,64 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Environments; + +/// +/// Deployment-environment management on a repository (GitHub Environments, +/// GitLab environments). Requires +/// . +/// Environment-scoped secrets are set via +/// with an +/// scope. +/// +public interface IGitEnvironmentService +{ + /// + /// Creates the environment when absent, updates it otherwise (idempotent). + /// + Task> EnsureAsync( + GitConnection connection, + GitRepositoryRef repository, + EnsureGitEnvironment request, + CancellationToken cancellationToken = default); + + /// + /// Deletes the environment. Deleting an absent environment succeeds (idempotent). + /// + Task DeleteAsync( + GitConnection connection, + GitRepositoryRef repository, + string environmentName, + CancellationToken cancellationToken = default); + + /// + /// Lists the repository's deployment environments. + /// + Task>> ListAsync( + GitConnection connection, + GitRepositoryRef repository, + CancellationToken cancellationToken = default); +} + +/// +/// Request to create or update a deployment environment. +/// +public sealed record EnsureGitEnvironment +{ + /// Gets the environment name (e.g. "production"). + public required string Name { get; init; } +} + +/// +/// A deployment environment on a repository. +/// +/// The environment name. +/// The web URL of the environment, when the provider reports one. +public sealed record GitDeploymentEnvironment(string Name, string? HtmlUrl = null); diff --git a/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs b/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs new file mode 100644 index 0000000..7c193e5 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs @@ -0,0 +1,144 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Capabilities; + +namespace Compendium.Abstractions.Git; + +/// +/// Provides standardized error definitions for git-server operations. Adapters +/// map provider responses onto these errors so consumers can handle failures +/// uniformly across providers. +/// +public static class GitErrors +{ + /// + /// Gets the error code prefix for git-server errors. + /// + public const string Prefix = "Git"; + + /// + /// The git server is not configured on this host (missing app registration, + /// credentials, or base URL). Returned by + /// and by adapters whose required options are absent. + /// + public static Error NotConfigured(string? provider = null) => + Error.Unavailable( + $"{Prefix}.NotConfigured", + provider is null + ? "No git server is configured on this host." + : $"The '{provider}' git server is not configured on this host."); + + /// + /// The operation requires a capability the provider does not support (or + /// supports only partially). Carries provider and capability + /// metadata; see the adapter's CAPABILITIES.md for the support matrix. + /// + public static Error NotSupported(string provider, GitCapability capability, string? limitation = null) + { + var metadata = new Dictionary + { + ["provider"] = provider, + ["capability"] = capability.ToString(), + }; + if (limitation is not null) + { + metadata["limitation"] = limitation; + } + + return Error.Unavailable( + $"{Prefix}.CapabilityNotSupported", + limitation is null + ? $"Provider '{provider}' does not support capability '{capability}'." + : $"Provider '{provider}' does not support capability '{capability}': {limitation}", + metadata); + } + + /// + /// The provider rejected the supplied credential (expired token, revoked + /// installation, bad signature on an app JWT, …). + /// + public static Error AuthenticationFailed(string provider, string? detail = null) => + Error.Unauthorized( + $"{Prefix}.AuthenticationFailed", + detail is null + ? $"Authentication against '{provider}' failed." + : $"Authentication against '{provider}' failed: {detail}"); + + /// + /// The platform app is not installed on the target namespace. The + /// installUrl metadata entry, when present, is the URL a user should + /// visit to install the app. + /// + public static Error AppNotInstalled(string @namespace, string? installUrl = null) + { + var metadata = new Dictionary { ["namespace"] = @namespace }; + if (installUrl is not null) + { + metadata["installUrl"] = installUrl; + } + + return Error.Failure( + $"{Prefix}.AppNotInstalled", + $"The platform app is not installed on '{@namespace}'.", + metadata); + } + + /// + /// The requested repository does not exist or is not visible to the credential. + /// + public static Error RepositoryNotFound(string repository) => + Error.NotFound($"{Prefix}.RepositoryNotFound", $"Repository '{repository}' was not found."); + + /// + /// The requested namespace (organization / group / user account) does not + /// exist or is not visible to the credential. + /// + public static Error NamespaceNotFound(string @namespace) => + Error.NotFound($"{Prefix}.NamespaceNotFound", $"Namespace '{@namespace}' was not found."); + + /// + /// A resource with the same identifier already exists (repository name, + /// namespace slug, environment name, …). + /// + public static Error Conflict(string resource) => + Error.Conflict($"{Prefix}.Conflict", $"'{resource}' already exists."); + + /// + /// The provider rejected the request because of rate limiting. Adapters + /// surface the provider's retry hint when available; callers must not retry + /// before it elapses. + /// + public static Error Throttled(TimeSpan? retryAfter = null) => + Error.TooManyRequests( + $"{Prefix}.Throttled", + retryAfter.HasValue + ? $"Git provider throttled the request. Retry after {retryAfter.Value.TotalSeconds:F0} seconds." + : "Git provider throttled the request. Please try again later.", + retryAfter.HasValue + ? new Dictionary { ["retryAfterSeconds"] = retryAfter.Value.TotalSeconds } + : null); + + /// + /// An inbound webhook delivery failed signature verification. The delivery + /// must be rejected without processing. + /// + public static Error WebhookSignatureInvalid() => + Error.Unauthorized( + $"{Prefix}.WebhookSignatureInvalid", + "The webhook delivery signature is missing or invalid."); + + /// + /// The provider rejected the request for a reason not covered by a more + /// specific error. Carries provider and statusCode metadata. + /// + public static Error ProviderRejected(string provider, int statusCode, string detail) => + Error.Failure( + $"{Prefix}.ProviderRejected", + $"Provider '{provider}' rejected the request ({statusCode}): {detail}", + new Dictionary { ["provider"] = provider, ["statusCode"] = statusCode }); +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs new file mode 100644 index 0000000..d2393ca --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs @@ -0,0 +1,8 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; diff --git a/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs b/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs new file mode 100644 index 0000000..a334405 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs @@ -0,0 +1,75 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.Capabilities; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; + +namespace Compendium.Abstractions.Git; + +/// +/// The git-server facade: one instance per provider adapter, carrying the +/// provider discriminator, the declarative capability matrix, and the +/// concern-scoped ports. Consumers resolve IEnumerable<IGitServer> +/// and dispatch on (the sub-ports are also registered +/// individually for single-concern consumers). +/// +/// +/// Implementations are stateless singletons: all per-tenant state travels in +/// the passed to every method, so a +/// single adapter instance serves any number of tenants concurrently. +/// +public interface IGitServer +{ + /// + /// Gets the provider identifier used for dispatch (e.g. "github", + /// "gitlab", "gitea"). + /// + string Provider { get; } + + /// + /// Gets the adapter's declared capability matrix. + /// + GitServerCapabilities Capabilities { get; } + + /// Gets the credential minting/validation/discovery port. + IGitCredentialBroker Credentials { get; } + + /// Gets the repository lifecycle and read port. + IGitRepositoryService Repositories { get; } + + /// Gets the CI pipeline port. + IGitPipelineService Pipelines { get; } + + /// Gets the CI secrets/variables port. + IGitCiConfigurationService CiConfiguration { get; } + + /// Gets the deployment-environments port. + IGitEnvironmentService Environments { get; } + + /// Gets the branch protection policies port. + IGitBranchPolicyService BranchPolicies { get; } + + /// Gets the teams and repository access port. + IGitAccessControlService AccessControl { get; } + + /// Gets the outgoing webhook subscription port. + IGitWebhookService Webhooks { get; } + + /// Gets the inbound webhook verification/parsing port. + IGitWebhookIngestor WebhookIngestor { get; } + + /// Gets the optional namespace provisioning port. + IGitNamespaceProvisioner NamespaceProvisioner { get; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs b/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs new file mode 100644 index 0000000..91cff0b --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs @@ -0,0 +1,148 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Pipelines; + +/// +/// CI pipeline operations: trigger runs and read their status. +/// +public interface IGitPipelineService +{ + /// + /// Triggers a pipeline run. Requires + /// . + /// + /// + /// Some providers do not return a run identifier on trigger (GitHub's + /// workflow_dispatch is fire-and-forget): the handle's + /// is there + /// and callers correlate via . Each adapter + /// documents this in its CAPABILITIES.md. + /// + Task> TriggerAsync( + GitConnection connection, + GitRepositoryRef repository, + TriggerGitPipeline request, + CancellationToken cancellationToken = default); + + /// + /// Gets a single pipeline run by identifier. Requires + /// . + /// + Task> GetRunAsync( + GitConnection connection, + GitRepositoryRef repository, + string runId, + CancellationToken cancellationToken = default); + + /// + /// Lists pipeline runs, newest first. Requires + /// . + /// + Task>> ListRunsAsync( + GitConnection connection, + GitRepositoryRef repository, + ListGitPipelineRuns query, + CancellationToken cancellationToken = default); +} + +/// +/// Request to trigger a pipeline run. +/// +public sealed record TriggerGitPipeline +{ + /// + /// Gets the pipeline selector. GitHub: the workflow file name or id + /// (e.g. "bootstrap.yml"); GitLab: the ref's .gitlab-ci.yml + /// pipeline. Each adapter documents its mapping. + /// + public required string Pipeline { get; init; } + + /// + /// Gets the git reference to run on — a branch or tag name, never a raw + /// commit SHA (GitHub rejects one; to run against a specific commit, pass a + /// branch/tag here and put the SHA in ). + /// + public required string Reference { get; init; } + + /// + /// Gets the input parameters passed to the pipeline, when it takes any. + /// + public IReadOnlyDictionary? Inputs { get; init; } +} + +/// +/// The immediate result of triggering a pipeline. +/// +/// +/// The created run's identifier, when the provider reports one at trigger time; +/// for fire-and-forget providers. +/// +public sealed record GitPipelineRunHandle(string? RunId); + +/// +/// A pipeline run as reported by the provider. +/// +/// The run identifier. +/// The pipeline the run belongs to. +/// The neutral run status. +/// The git reference the run executed on. +/// The web URL of the run. +/// When the run was created. +public sealed record GitPipelineRun( + string Id, + string Pipeline, + GitPipelineStatus Status, + string Reference, + string HtmlUrl, + DateTimeOffset CreatedAt); + +/// +/// The neutral status of a pipeline run. Adapters map provider states onto this +/// set and use for states with no equivalent. +/// +public enum GitPipelineStatus +{ + /// The run is waiting to start. + Queued, + + /// The run is executing. + Running, + + /// The run completed successfully. + Succeeded, + + /// The run completed with a failure. + Failed, + + /// The run was cancelled before completion. + Cancelled, + + /// The run was skipped. + Skipped, + + /// The provider reported a state with no neutral equivalent. + Unknown, +} + +/// +/// Filters for . +/// +public sealed record ListGitPipelineRuns +{ + /// Gets the pipeline to filter on; all pipelines when null. + public string? Pipeline { get; init; } + + /// Gets the git reference to filter on; all references when null. + public string? Reference { get; init; } + + /// Gets the maximum number of runs returned (a single page). Defaults to 20. + public int Limit { get; init; } = 20; +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs b/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs new file mode 100644 index 0000000..ecb5057 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs @@ -0,0 +1,89 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Protection; + +/// +/// Branch protection policies on a repository. The policy model is neutral: +/// adapters map it onto their native mechanism (GitHub: repository rulesets; +/// GitLab: protected branches + push rules) and document lossy mappings in +/// their CAPABILITIES.md. Requires +/// . +/// +public interface IGitBranchPolicyService +{ + /// + /// Applies a protection policy. When a policy with the same + /// already exists, it is + /// updated (idempotent). + /// + Task> ApplyAsync( + GitConnection connection, + GitRepositoryRef repository, + GitBranchPolicyRequest request, + CancellationToken cancellationToken = default); + + /// + /// Removes a policy by identifier. Removing an absent policy succeeds (idempotent). + /// + Task RemoveAsync( + GitConnection connection, + GitRepositoryRef repository, + string policyId, + CancellationToken cancellationToken = default); + + /// + /// Lists the repository's protection policies. + /// + Task>> ListAsync( + GitConnection connection, + GitRepositoryRef repository, + CancellationToken cancellationToken = default); +} + +/// +/// A neutral branch protection policy request. +/// +public sealed record GitBranchPolicyRequest +{ + /// Gets the branch name pattern the policy targets (e.g. "main", "release/*"). + public required string Pattern { get; init; } + + /// Gets whether changes must go through a pull/merge request. Defaults to true. + public bool RequirePullRequest { get; init; } = true; + + /// Gets the number of approvals required on the pull request. + public int RequiredApprovals { get; init; } + + /// Gets whether stale approvals are dismissed when new commits are pushed. + public bool DismissStaleApprovals { get; init; } + + /// Gets the CI status checks that must pass before merging, when any. + public IReadOnlyList? RequiredStatusChecks { get; init; } + + /// Gets whether force pushes to matching branches are blocked. Defaults to true. + public bool BlockForcePush { get; init; } = true; + + /// Gets whether deletion of matching branches is blocked. Defaults to true. + public bool BlockDeletion { get; init; } = true; + + /// Gets whether a linear history (no merge commits) is required. + public bool RequireLinearHistory { get; init; } + + /// Gets whether the policy also applies to repository administrators. + public bool EnforceForAdmins { get; init; } +} + +/// +/// An applied branch protection policy. +/// +/// The provider-side policy identifier. +/// The branch name pattern the policy targets. +public sealed record GitBranchPolicy(string Id, string Pattern); diff --git a/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs b/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs new file mode 100644 index 0000000..0c4ac99 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; + +namespace Compendium.Abstractions.Git.Provisioning; + +/// +/// OPTIONAL capability: creates the namespace (organization/group) itself. +/// Trivial on GitLab (POST /groups) and Gitea (POST /orgs); +/// on github.com it requires an enterprise-owner user token and is declared +/// at best; absent on +/// Azure DevOps. Always check +/// first. +/// +public interface IGitNamespaceProvisioner +{ + /// + /// Creates a namespace. Fails with Git.Conflict when the slug is + /// already taken. + /// + Task> CreateNamespaceAsync( + GitConnection connection, + CreateGitNamespace request, + CancellationToken cancellationToken = default); +} + +/// +/// Request to create a namespace. +/// +public sealed record CreateGitNamespace +{ + /// Gets the namespace slug/login (e.g. "NXS-Acme"). + public required string Name { get; init; } + + /// Gets the display/profile name; defaults to . + public string? DisplayName { get; init; } + + /// Gets the billing email, where the provider requires one (GitHub enterprise org creation). + public string? BillingEmail { get; init; } + + /// Gets the logins granted admin/owner on the new namespace, where the provider supports it. + public IReadOnlyList? AdminLogins { get; init; } +} + +/// +/// A created namespace. +/// +/// The namespace slug/login. +/// The web URL of the namespace, when the provider reports one. +public sealed record GitNamespace(string Name, string? HtmlUrl = null); diff --git a/src/Abstractions/Compendium.Abstractions.Git/README.md b/src/Abstractions/Compendium.Abstractions.Git/README.md new file mode 100644 index 0000000..61d87a1 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/README.md @@ -0,0 +1,31 @@ +# Compendium.Abstractions.Git + +Provider-agnostic git-server ports for the Compendium Framework. + +A platform that drives a git server (create repositories from templates, configure CI +secrets/variables, trigger pipelines, manage deployment environments, branch policies, +teams, and webhooks) programs against the `IGitServer` facade and its concern-scoped +sub-ports. Concrete providers (GitHub, GitLab, Gitea/Forgejo, Azure DevOps, …) are +supplied by `Compendium.Adapters.*` packages. + +## Design + +- **`IGitServer`** — facade carrying the `Provider` discriminator, the declarative + `GitServerCapabilities`, and the sub-ports. Consumers resolve `IEnumerable` + and dispatch on `Provider`. +- **`GitConnection` / `GitCredential`** — every call takes an explicit connection. + Credentials are a closed union: platform app installation (GitHub App), durable + service-account token (GitLab group token, Gitea bot PAT), one-shot personal access + token, or OAuth user token. Token-bearing records redact in `ToString()`. +- **`GitServerCapabilities`** — adapters declare what they support (`None`/`Partial`/`Full` + per `GitCapability`). Unsupported operations fail with `Git.CapabilityNotSupported` + (`Result` failure, never an exception). Each adapter ships a `CAPABILITIES.md` matrix. +- **`GitWebhookEvent`** — neutral inbound-event union produced by `IGitWebhookIngestor` + from a raw, signature-verified delivery. +- All operations return `Result`/`Result` from `Compendium.Core.Results`. + +## Testing + +`Compendium.Testing` provides `InMemoryGitServer` (full-fidelity fake) and +`GitServerContractTests` (the behavioral contract every adapter must pass). +This package also ships `NullGitServer`, a fail-fast stub for unconfigured hosts. diff --git a/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs b/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs new file mode 100644 index 0000000..6d705c9 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs @@ -0,0 +1,116 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Git.Repositories; + +/// +/// Identifies a repository by its namespace (organization/group/user login) and name. +/// +/// The owning namespace login (e.g. "acme"). +/// The repository name (e.g. "billing-api"). +public sealed record GitRepositoryRef(string Namespace, string Name) +{ + /// + /// Gets the namespace/name form of the reference. + /// + public string FullName => $"{Namespace}/{Name}"; + + /// + public override string ToString() => FullName; +} + +/// +/// A repository as reported by the provider. +/// +/// The repository reference. +/// The HTTPS clone URL. +/// The web URL of the repository. +/// The default branch name (e.g. "main"). +/// Whether the repository is private. +public sealed record GitRepository( + GitRepositoryRef Ref, + string CloneUrl, + string HtmlUrl, + string DefaultBranch, + bool Private); + +/// +/// A single commit, surfaced so a caller can pick a commit to deploy. +/// +/// The full commit SHA. +/// The commit message (first line is the summary). +/// The author's display name, when available. +/// When the commit was authored, when available. +/// A web URL to view the commit. +public sealed record GitCommit( + string Sha, + string Message, + string? AuthorName, + DateTimeOffset? AuthoredAt, + string HtmlUrl); + +/// +/// A branch on a repository. +/// +/// The branch name (e.g. "main"). +/// The commit SHA the branch currently points at. +/// Whether the branch is protected. +public sealed record GitBranch(string Name, string Sha, bool Protected); + +/// +/// A tag on a repository. +/// +/// The tag name (e.g. "v1.2.3"). +/// The commit SHA the tag points at. +public sealed record GitTag(string Name, string Sha); + +/// +/// A published release. +/// +/// The provider-side release identifier. +/// The tag the release was published from. +/// The web URL of the release. +public sealed record GitRelease(string Id, string TagName, string HtmlUrl); + +/// +/// Request to create a repository from a template/scaffold repository. +/// +public sealed record CreateRepositoryFromTemplate +{ + /// Gets the template repository to instantiate. + public required GitRepositoryRef Template { get; init; } + + /// Gets the namespace the new repository is created under. + public required string Namespace { get; init; } + + /// Gets the new repository's name. + public required string Name { get; init; } + + /// Gets the optional repository description. + public string? Description { get; init; } + + /// Gets whether the new repository is private. Defaults to true. + public bool Private { get; init; } = true; +} + +/// +/// Request to publish a release. +/// +public sealed record CreateGitRelease +{ + /// Gets the tag to release. Created at when absent. + public required string TagName { get; init; } + + /// Gets the commit SHA to tag when the tag does not exist yet; defaults to the head of the default branch. + public string? TargetCommitSha { get; init; } + + /// Gets the release title; defaults to the tag name. + public string? Title { get; init; } + + /// Gets the release notes body, when any. + public string? Body { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs b/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs new file mode 100644 index 0000000..7469d02 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs @@ -0,0 +1,109 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; + +namespace Compendium.Abstractions.Git.Repositories; + +/// +/// Repository lifecycle and read operations: create from template, inspect +/// contents, list commits/branches/tags, create tags and releases. +/// +public interface IGitRepositoryService +{ + /// + /// Creates a repository from a template repository. Requires + /// . + /// + Task> CreateFromTemplateAsync( + GitConnection connection, + CreateRepositoryFromTemplate request, + CancellationToken cancellationToken = default); + + /// + /// Gets a repository by reference. Fails with Git.RepositoryNotFound + /// when absent or not visible to the credential. + /// + Task> GetAsync( + GitConnection connection, + GitRepositoryRef repository, + CancellationToken cancellationToken = default); + + /// + /// Lists the repositories of visible to the + /// connection's credential. + /// + Task>> ListAsync( + GitConnection connection, + string @namespace, + CancellationToken cancellationToken = default); + + /// + /// Checks whether exists in the repository at + /// (default branch when null). Used e.g. to + /// verify a repository finished bootstrapping before tagging a release. + /// + Task> FileExistsAsync( + GitConnection connection, + GitRepositoryRef repository, + string path, + string? gitRef = null, + CancellationToken cancellationToken = default); + + /// + /// Lists recent commits, newest first. + /// + /// The connection to operate with. + /// The repository to read. + /// Optional branch/ref/SHA to list from; the default branch when null or empty. + /// Maximum number of commits returned (a single page). + /// A cancellation token. + Task>> ListCommitsAsync( + GitConnection connection, + GitRepositoryRef repository, + string? reference, + int limit, + CancellationToken cancellationToken = default); + + /// + /// Lists the repository's branches. + /// + Task>> ListBranchesAsync( + GitConnection connection, + GitRepositoryRef repository, + CancellationToken cancellationToken = default); + + /// + /// Creates a tag. When is null, tags the head + /// of the default branch. Requires + /// . + /// + Task> CreateTagAsync( + GitConnection connection, + GitRepositoryRef repository, + string tagName, + string? commitSha = null, + CancellationToken cancellationToken = default); + + /// + /// Lists the repository's tags. + /// + Task>> ListTagsAsync( + GitConnection connection, + GitRepositoryRef repository, + CancellationToken cancellationToken = default); + + /// + /// Publishes a release. Requires + /// . + /// + Task> CreateReleaseAsync( + GitConnection connection, + GitRepositoryRef repository, + CreateGitRelease request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs b/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs new file mode 100644 index 0000000..b7b8347 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs @@ -0,0 +1,293 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.Capabilities; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; + +namespace Compendium.Abstractions.Git.Stubs; + +/// +/// Fail-fast stub for hosts without a configured git server: every operation +/// returns Git.NotConfigured and the capability matrix is empty. Register +/// it in the unconfigured DI branch so a misconfigured deployment fails loudly +/// per-request instead of throwing, and never with fake data. +/// +public sealed class NullGitServer : + IGitServer, + IGitCredentialBroker, + IGitRepositoryService, + IGitPipelineService, + IGitCiConfigurationService, + IGitEnvironmentService, + IGitBranchPolicyService, + IGitAccessControlService, + IGitWebhookService, + IGitWebhookIngestor, + IGitNamespaceProvisioner +{ + /// + /// The provider discriminator reported by the stub. + /// + public const string ProviderName = "null"; + + /// + public string Provider => ProviderName; + + /// + public GitServerCapabilities Capabilities { get; } = new() + { + Provider = ProviderName, + Entries = new Dictionary(), + }; + + /// + public IGitCredentialBroker Credentials => this; + + /// + public IGitRepositoryService Repositories => this; + + /// + public IGitPipelineService Pipelines => this; + + /// + public IGitCiConfigurationService CiConfiguration => this; + + /// + public IGitEnvironmentService Environments => this; + + /// + public IGitBranchPolicyService BranchPolicies => this; + + /// + public IGitAccessControlService AccessControl => this; + + /// + public IGitWebhookService Webhooks => this; + + /// + public IGitWebhookIngestor WebhookIngestor => this; + + /// + public IGitNamespaceProvisioner NamespaceProvisioner => this; + + private static Result NotConfigured() => Result.Failure(GitErrors.NotConfigured()); + + private static Result NotConfigured() => Result.Failure(GitErrors.NotConfigured()); + + private static Task NotConfiguredTask() => Task.FromResult(NotConfigured()); + + private static Task> NotConfiguredTask() => Task.FromResult(NotConfigured()); + + /// + public Task> MintAsync( + GitConnection connection, GitAccessTokenScope? scope = null, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> ValidateAsync( + GitConnection connection, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> ResolveAppInstallationAsync( + string @namespace, string? appKey = null, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListAppInstallationsAsync( + string? appKey = null, CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task> CreateFromTemplateAsync( + GitConnection connection, CreateRepositoryFromTemplate request, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> GetAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListAsync( + GitConnection connection, string @namespace, CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task> FileExistsAsync( + GitConnection connection, GitRepositoryRef repository, string path, string? gitRef = null, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListCommitsAsync( + GitConnection connection, GitRepositoryRef repository, string? reference, int limit, + CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task>> ListBranchesAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task> CreateTagAsync( + GitConnection connection, GitRepositoryRef repository, string tagName, string? commitSha = null, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListTagsAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task> CreateReleaseAsync( + GitConnection connection, GitRepositoryRef repository, CreateGitRelease request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> TriggerAsync( + GitConnection connection, GitRepositoryRef repository, TriggerGitPipeline request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> GetRunAsync( + GitConnection connection, GitRepositoryRef repository, string runId, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListRunsAsync( + GitConnection connection, GitRepositoryRef repository, ListGitPipelineRuns query, + CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task SetSecretsAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary secrets, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task DeleteSecretAsync( + GitConnection connection, GitConfigurationScope scope, string name, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task SetVariablesAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary variables, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task DeleteVariableAsync( + GitConnection connection, GitConfigurationScope scope, string name, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> EnsureAsync( + GitConnection connection, GitRepositoryRef repository, EnsureGitEnvironment request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task DeleteAsync( + GitConnection connection, GitRepositoryRef repository, string environmentName, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task>> ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + => NotConfiguredTask>(); + + /// + public Task> ApplyAsync( + GitConnection connection, GitRepositoryRef repository, GitBranchPolicyRequest request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task RemoveAsync( + GitConnection connection, GitRepositoryRef repository, string policyId, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + Task>> IGitBranchPolicyService.ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken) + => NotConfiguredTask>(); + + /// + public Task> EnsureTeamAsync( + GitConnection connection, string @namespace, EnsureGitTeam request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task AddTeamMemberAsync( + GitConnection connection, string @namespace, string teamSlug, string username, GitTeamRole role, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task SetTeamRepositoryRoleAsync( + GitConnection connection, string @namespace, string teamSlug, GitRepositoryRef repository, + GitRepositoryRole role, CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task SetUserRepositoryRoleAsync( + GitConnection connection, GitRepositoryRef repository, string username, GitRepositoryRole role, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task RemoveUserFromRepositoryAsync( + GitConnection connection, GitRepositoryRef repository, string username, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task> EnsureAsync( + GitConnection connection, GitWebhookTarget target, EnsureGitWebhook request, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + public Task DeleteAsync( + GitConnection connection, GitWebhookTarget target, string subscriptionId, + CancellationToken cancellationToken = default) + => NotConfiguredTask(); + + /// + Task>> IGitWebhookService.ListAsync( + GitConnection connection, GitWebhookTarget target, CancellationToken cancellationToken) + => NotConfiguredTask>(); + + /// + public Result Parse(GitWebhookDelivery delivery, string secret) + => NotConfigured(); + + /// + public Task> CreateNamespaceAsync( + GitConnection connection, CreateGitNamespace request, CancellationToken cancellationToken = default) + => NotConfiguredTask(); +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs new file mode 100644 index 0000000..35ee182 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs @@ -0,0 +1,148 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Webhooks; + +/// +/// Inbound side of webhooks: verifies a raw delivery's signature and parses it +/// into the neutral union. Pure and synchronous — +/// it owns no HTTP endpoint; the host maps its own route and hands the raw +/// body/headers here. Requires +/// . +/// +public interface IGitWebhookIngestor +{ + /// + /// Verifies the delivery signature against + /// (fail-closed: missing or invalid signatures return + /// Git.WebhookSignatureInvalid) and parses the payload. Event types + /// the platform does not consume parse to + /// — callers acknowledge those + /// with a 2xx and never retry. + /// + Result Parse(GitWebhookDelivery delivery, string secret); +} + +/// +/// A raw inbound webhook delivery as received over HTTP. +/// +public sealed record GitWebhookDelivery +{ + /// + /// Gets the raw request body exactly as received — signature verification + /// runs over these bytes, so the body must not be re-serialized upstream. + /// + public required string Body { get; init; } + + /// + /// Gets the request headers. Lookups are case-insensitive; hosts should + /// build the dictionary with . + /// + public required IReadOnlyDictionary Headers { get; init; } +} + +/// +/// A neutral, provider-agnostic inbound webhook event, as a closed union for +/// exhaustive pattern matching. +/// +public abstract record GitWebhookEvent +{ + /// + /// Gets the provider-assigned delivery identifier, used for idempotent + /// processing (the same delivery may be received more than once). + /// + public required string DeliveryId { get; init; } + + /// + /// Gets the repository the event concerns, when the event is repository-scoped. + /// + public GitRepositoryRef? Repository { get; init; } + + /// + /// Commits were pushed to a branch. + /// + /// The pushed reference (e.g. "refs/heads/main"). + /// The SHA of the new head commit. + public sealed record Push(string Reference, string HeadCommitSha) : GitWebhookEvent; + + /// + /// A tag was pushed. + /// + /// The tag name. + /// The SHA the tag points at. + public sealed record TagPushed(string Tag, string CommitSha) : GitWebhookEvent; + + /// + /// A pull/merge request changed state. + /// + /// The provider-native action (e.g. "opened", "closed"). + /// The pull request number. + /// The source branch. + /// The target branch. + public sealed record PullRequestChanged( + string Action, + int Number, + string SourceReference, + string TargetReference) : GitWebhookEvent; + + /// + /// A pipeline run reached a terminal state. + /// + /// The run identifier. + /// The pipeline the run belongs to. + /// The neutral terminal status. + /// The git reference the run executed on. + public sealed record PipelineRunCompleted( + string RunId, + string Pipeline, + Pipelines.GitPipelineStatus Status, + string Reference) : GitWebhookEvent; + + /// + /// The platform app's installation on an account changed (installed, + /// suspended, unsuspended, repositories added/removed, or uninstalled). + /// + /// The account login the installation lives on. + /// Whether that account is an organization or a user. + /// The provider-side installation identifier. + /// The neutral change kind. + public sealed record ConnectionChanged( + string Namespace, + Connections.GitAccountType AccountType, + string InstallationId, + GitConnectionChangeKind Change) : GitWebhookEvent; + + /// + /// An event type the platform does not consume. Acknowledge with a 2xx and + /// do not retry. + /// + /// The provider-native event type name. + public sealed record Unsupported(string ProviderEventType) : GitWebhookEvent; +} + +/// +/// The kind of change reported by . +/// +public enum GitConnectionChangeKind +{ + /// The app was installed on the account. + Installed, + + /// The installation was suspended. + Suspended, + + /// The installation was unsuspended. + Unsuspended, + + /// The set of repositories the installation can access changed. + RepositoriesChanged, + + /// The app was uninstalled from the account. + Uninstalled, +} diff --git a/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs new file mode 100644 index 0000000..2b4ad19 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs @@ -0,0 +1,108 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Webhooks; + +/// +/// Outgoing webhook subscription management on a repository or namespace. +/// Requires . +/// +public interface IGitWebhookService +{ + /// + /// Creates the subscription when absent (matched by URL), updates it + /// otherwise (idempotent). + /// + Task> EnsureAsync( + GitConnection connection, + GitWebhookTarget target, + EnsureGitWebhook request, + CancellationToken cancellationToken = default); + + /// + /// Deletes a subscription by identifier. Deleting an absent subscription + /// succeeds (idempotent). + /// + Task DeleteAsync( + GitConnection connection, + GitWebhookTarget target, + string subscriptionId, + CancellationToken cancellationToken = default); + + /// + /// Lists the target's webhook subscriptions. + /// + Task>> ListAsync( + GitConnection connection, + GitWebhookTarget target, + CancellationToken cancellationToken = default); +} + +/// +/// Where a webhook subscription lives, as a closed union. +/// +public abstract record GitWebhookTarget +{ + private GitWebhookTarget() + { + } + + /// + /// A repository-level webhook. + /// + /// The repository. + public sealed record Repository(GitRepositoryRef Ref) : GitWebhookTarget; + + /// + /// A namespace-level (organization/group) webhook. + /// + /// The namespace login. + public sealed record Namespace(string Name) : GitWebhookTarget; +} + +/// +/// Request to create or update a webhook subscription. +/// +public sealed record EnsureGitWebhook +{ + /// Gets the delivery URL. + public required Uri Url { get; init; } + + /// + /// Gets the shared secret used to sign deliveries. Redacted in ToString(). + /// + public required string Secret { get; init; } + + /// + /// Gets the provider-native event names to subscribe to (e.g. GitHub + /// "push", "workflow_run"). Each adapter documents its + /// accepted names. + /// + public required IReadOnlyList Events { get; init; } + + /// Gets whether the subscription is active. Defaults to true. + public bool Active { get; init; } = true; + + /// + public override string ToString() => $"EnsureGitWebhook(Url={Url}, Secret=***, Events=[{string.Join(", ", Events)}])"; +} + +/// +/// A webhook subscription as reported by the provider. +/// +/// The provider-side subscription identifier. +/// The delivery URL. +/// The provider-native event names subscribed to. +/// Whether the subscription is active. +public sealed record GitWebhookSubscription( + string Id, + Uri Url, + IReadOnlyList Events, + bool Active); diff --git a/src/Testing/Compendium.Testing/Compendium.Testing.csproj b/src/Testing/Compendium.Testing/Compendium.Testing.csproj index d3133b7..2124504 100644 --- a/src/Testing/Compendium.Testing/Compendium.Testing.csproj +++ b/src/Testing/Compendium.Testing/Compendium.Testing.csproj @@ -10,6 +10,7 @@ + @@ -17,6 +18,11 @@ + + + + diff --git a/src/Testing/Compendium.Testing/Git/GitServerContractTests.cs b/src/Testing/Compendium.Testing/Git/GitServerContractTests.cs new file mode 100644 index 0000000..9d5ae50 --- /dev/null +++ b/src/Testing/Compendium.Testing/Git/GitServerContractTests.cs @@ -0,0 +1,283 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git; +using Compendium.Abstractions.Git.Capabilities; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using FluentAssertions; +using Xunit; + +namespace Compendium.Testing.Git; + +/// +/// The behavioral contract every adapter must satisfy. +/// Inherit in the adapter's test suite and provide the fixture members; tests +/// for capabilities the adapter does not declare are skipped automatically +/// (capability honesty is itself asserted: a declared capability must work). +/// subscribes to this contract, keeping the +/// fake and real adapters aligned. +/// +public abstract class GitServerContractTests +{ + /// Gets the server under test. + protected abstract IGitServer Server { get; } + + /// Gets a connection with valid credentials for . + protected abstract GitConnection Connection { get; } + + /// Gets a namespace where the connection can create repositories. + protected abstract string Namespace { get; } + + /// Gets a template repository that exists and can be instantiated. + protected abstract GitRepositoryRef TemplateRepository { get; } + + /// Gets the webhook secret used when building signed deliveries. + protected abstract string WebhookSecret { get; } + + /// + /// Builds a raw webhook delivery for : + /// signed with when + /// is true, deliberately mis-signed otherwise. + /// + protected abstract GitWebhookDelivery CreateDelivery(bool validSignature); + + /// Generates a unique repository name for a test run. + protected virtual string NewRepositoryName() => $"contract-{Guid.NewGuid():N}"[..24]; + + /// The declared provider must match the facade discriminator and never be empty. + [Fact] + public void Capabilities_DeclaresTheServerProvider() + { + Server.Provider.Should().NotBeNullOrWhiteSpace(); + Server.Capabilities.Provider.Should().Be(Server.Provider); + } + + /// An undeclared capability must fail with the standard machine code, never throw. + [SkippableFact] + public void EnsureSupported_UndeclaredCapability_FailsWithCapabilityNotSupported() + { + var undeclared = Enum.GetValues() + .FirstOrDefault(c => !Server.Capabilities.Supports(c), (GitCapability)(-1)); + Skip.If(undeclared == (GitCapability)(-1), "Adapter declares every capability."); + + var result = Server.Capabilities.EnsureSupported(undeclared); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{GitErrors.Prefix}.CapabilityNotSupported"); + } + + /// Minted tokens are non-empty, not yet expired, and carry a basic-auth username. + [SkippableFact] + public async Task Mint_ReturnsUsableShortLivedToken() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.AppInstallationAuth) + || Server.Capabilities.Supports(GitCapability.ServiceAccountAuth)); + + var result = await Server.Credentials.MintAsync(Connection); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Token.Should().NotBeNullOrWhiteSpace(); + result.Value.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); + result.Value.HttpBasicUsername.Should().NotBeNullOrWhiteSpace(); + result.Value.ToString().Should().NotContain(result.Value.Token, "tokens must be redacted in ToString()"); + } + + /// A valid credential validates and reports an identity. + [Fact] + public async Task Validate_ValidCredential_ReportsIdentity() + { + var result = await Server.Credentials.ValidateAsync(Connection); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.AccountLogin.Should().NotBeNullOrWhiteSpace(); + } + + /// Repository creation from a template round-trips through Get, and repeating the name conflicts. + [SkippableFact] + public async Task CreateFromTemplate_RoundTrips_AndDuplicateFails() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate)); + + var request = new CreateRepositoryFromTemplate + { + Template = TemplateRepository, + Namespace = Namespace, + Name = NewRepositoryName(), + }; + + var created = await Server.Repositories.CreateFromTemplateAsync(Connection, request); + created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty); + created.Value.Ref.Namespace.Should().BeEquivalentTo(request.Namespace); + created.Value.CloneUrl.Should().NotBeNullOrWhiteSpace(); + + var fetched = await Server.Repositories.GetAsync(Connection, created.Value.Ref); + fetched.IsSuccess.Should().BeTrue(); + + var duplicate = await Server.Repositories.CreateFromTemplateAsync(Connection, request); + duplicate.IsFailure.Should().BeTrue("creating the same repository twice must fail"); + } + + /// Reading an absent repository fails with RepositoryNotFound, never throws. + [SkippableFact] + public async Task Get_AbsentRepository_FailsWithRepositoryNotFound() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryManagement)); + + var result = await Server.Repositories.GetAsync( + Connection, new GitRepositoryRef(Namespace, NewRepositoryName())); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{GitErrors.Prefix}.RepositoryNotFound"); + } + + /// Secrets set at repository scope succeed and deleting an absent secret is idempotent. + [SkippableFact] + public async Task SetSecrets_Succeeds_AndDeleteIsIdempotent() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate) + && Server.Capabilities.Supports(GitCapability.CiSecrets)); + + var repo = await CreateRepositoryAsync(); + var scope = new GitConfigurationScope.Repository(repo); + + var set = await Server.CiConfiguration.SetSecretsAsync( + Connection, scope, new Dictionary { ["CONTRACT_SECRET"] = "s3cret" }); + set.IsSuccess.Should().BeTrue(set.IsFailure ? set.Error.Message : string.Empty); + + var deleteAbsent = await Server.CiConfiguration.DeleteSecretAsync(Connection, scope, "NEVER_SET"); + deleteAbsent.IsSuccess.Should().BeTrue("deleting an absent secret must be idempotent"); + } + + /// Triggering a pipeline succeeds; when a run id is returned, its status is readable. + [SkippableFact] + public async Task TriggerPipeline_Succeeds_AndRunIsReadableWhenIdReturned() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate) + && Server.Capabilities.Supports(GitCapability.PipelineTrigger)); + + var repo = await CreateRepositoryAsync(); + var triggered = await Server.Pipelines.TriggerAsync(Connection, repo, new TriggerGitPipeline + { + Pipeline = "bootstrap.yml", + Reference = "main", + }); + + triggered.IsSuccess.Should().BeTrue(triggered.IsFailure ? triggered.Error.Message : string.Empty); + + if (triggered.Value.RunId is { } runId && Server.Capabilities.Supports(GitCapability.PipelineStatus)) + { + var run = await Server.Pipelines.GetRunAsync(Connection, repo, runId); + run.IsSuccess.Should().BeTrue(run.IsFailure ? run.Error.Message : string.Empty); + run.Value.Id.Should().Be(runId); + } + } + + /// Ensuring the same environment twice is idempotent. + [SkippableFact] + public async Task EnsureEnvironment_IsIdempotent() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate) + && Server.Capabilities.Supports(GitCapability.DeploymentEnvironments)); + + var repo = await CreateRepositoryAsync(); + var request = new EnsureGitEnvironment { Name = "production" }; + + (await Server.Environments.EnsureAsync(Connection, repo, request)).IsSuccess.Should().BeTrue(); + (await Server.Environments.EnsureAsync(Connection, repo, request)).IsSuccess.Should().BeTrue(); + + var list = await Server.Environments.ListAsync(Connection, repo); + list.IsSuccess.Should().BeTrue(); + list.Value.Should().ContainSingle(e => e.Name == "production"); + } + + /// Applying the same branch policy twice is idempotent. + [SkippableFact] + public async Task ApplyBranchPolicy_IsIdempotent() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate) + && Server.Capabilities.Supports(GitCapability.BranchPolicies)); + + var repo = await CreateRepositoryAsync(); + var request = new GitBranchPolicyRequest { Pattern = "main" }; + + (await Server.BranchPolicies.ApplyAsync(Connection, repo, request)).IsSuccess.Should().BeTrue(); + (await Server.BranchPolicies.ApplyAsync(Connection, repo, request)).IsSuccess.Should().BeTrue(); + + var list = await Server.BranchPolicies.ListAsync(Connection, repo); + list.IsSuccess.Should().BeTrue(); + list.Value.Should().ContainSingle(p => p.Pattern == "main"); + } + + /// Ensuring the same webhook URL twice yields a single subscription. + [SkippableFact] + public async Task EnsureWebhook_SameUrl_IsIdempotent() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.RepositoryFromTemplate) + && Server.Capabilities.Supports(GitCapability.WebhookManagement)); + + var repo = await CreateRepositoryAsync(); + var target = new GitWebhookTarget.Repository(repo); + var request = new EnsureGitWebhook + { + Url = new Uri("https://platform.invalid/webhooks/git"), + Secret = WebhookSecret, + Events = ["push"], + }; + + var first = await Server.Webhooks.EnsureAsync(Connection, target, request); + var second = await Server.Webhooks.EnsureAsync(Connection, target, request); + first.IsSuccess.Should().BeTrue(first.IsFailure ? first.Error.Message : string.Empty); + second.IsSuccess.Should().BeTrue(); + second.Value.Id.Should().Be(first.Value.Id, "ensuring the same URL twice must not duplicate"); + + var list = await Server.Webhooks.ListAsync(Connection, target); + list.IsSuccess.Should().BeTrue(); + list.Value.Should().ContainSingle(s => s.Url == request.Url); + } + + /// The ingestor is fail-closed: an invalid signature is rejected before parsing. + [SkippableFact] + public void Ingestor_InvalidSignature_IsRejected() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.WebhookIngestion)); + + var result = Server.WebhookIngestor.Parse(CreateDelivery(validSignature: false), WebhookSecret); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{GitErrors.Prefix}.WebhookSignatureInvalid"); + } + + /// A correctly signed delivery parses to a neutral event with a delivery id. + [SkippableFact] + public void Ingestor_ValidSignature_ParsesToNeutralEvent() + { + Skip.IfNot(Server.Capabilities.Supports(GitCapability.WebhookIngestion)); + + var result = Server.WebhookIngestor.Parse(CreateDelivery(validSignature: true), WebhookSecret); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.DeliveryId.Should().NotBeNullOrWhiteSpace(); + } + + private async Task CreateRepositoryAsync() + { + var created = await Server.Repositories.CreateFromTemplateAsync(Connection, new CreateRepositoryFromTemplate + { + Template = TemplateRepository, + Namespace = Namespace, + Name = NewRepositoryName(), + }); + created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty); + return created.Value.Ref; + } +} diff --git a/src/Testing/Compendium.Testing/Git/InMemoryGitServer.cs b/src/Testing/Compendium.Testing/Git/InMemoryGitServer.cs new file mode 100644 index 0000000..784406a --- /dev/null +++ b/src/Testing/Compendium.Testing/Git/InMemoryGitServer.cs @@ -0,0 +1,1049 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json; +using Compendium.Abstractions.Git; +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.Capabilities; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using Compendium.Core.Results; + +namespace Compendium.Testing.Git; + +/// +/// Full-fidelity in-memory fake for tests and dev mode. +/// Declares every capability as and keeps +/// all state in memory: repositories created from templates, secret NAMES +/// (values are discarded — mirroring the write-only semantics of real +/// providers), variables, deployment environments, branch policies, teams, +/// webhook subscriptions, and pipeline runs. +/// +/// +/// Thread-safe. Test hooks: , +/// , , +/// , and the log. Inbound webhook +/// deliveries are parsed from a simple JSON envelope signed by equality with +/// the shared secret via the X-InMemory-Signature header (fail-closed). +/// +public sealed class InMemoryGitServer : + IGitServer, + IGitCredentialBroker, + IGitRepositoryService, + IGitPipelineService, + IGitCiConfigurationService, + IGitEnvironmentService, + IGitBranchPolicyService, + IGitAccessControlService, + IGitWebhookService, + IGitWebhookIngestor, + IGitNamespaceProvisioner +{ + /// The provider discriminator reported by the fake. + public const string ProviderName = "in-memory"; + + /// The webhook secret accepted by when signing test deliveries. + public const string WellKnownWebhookSecret = "in-memory-webhook-secret"; + + private readonly object _gate = new(); + private readonly List _calls = []; + private readonly HashSet _namespaces = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _repositories = new(StringComparer.OrdinalIgnoreCase); + private readonly List _installations = []; + private readonly Dictionary> _secretsByScope = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _variablesByScope = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _runs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _teams = new(StringComparer.OrdinalIgnoreCase); + private int _nextRunId; + private int _nextWebhookId; + private int _nextReleaseId; + + /// + /// Gets the identity reported by for + /// token-based credentials. + /// + public GitConnectionIdentity DefaultIdentity { get; init; } = + new("in-memory-user", GitAccountType.User, "In-Memory User"); + + /// + /// Gets the paths seeded into every repository created from a template, so + /// bootstrap-marker checks (.bootstrapped) succeed in dev mode + /// without an explicit call. + /// + public IReadOnlyList InitialFiles { get; init; } = [".bootstrapped"]; + + /// + /// Gets a snapshot of the operations performed, as + /// "MethodName arg1 arg2" entries, for order/interaction assertions. + /// + public IReadOnlyList Calls + { + get + { + lock (_gate) + { + return [.. _calls]; + } + } + } + + /// + public string Provider => ProviderName; + + /// + public GitServerCapabilities Capabilities { get; } = new() + { + Provider = ProviderName, + Entries = Enum.GetValues() + .ToDictionary(c => c, _ => new GitCapabilitySupport(GitCapabilityLevel.Full)), + }; + + /// + public IGitCredentialBroker Credentials => this; + + /// + public IGitRepositoryService Repositories => this; + + /// + public IGitPipelineService Pipelines => this; + + /// + public IGitCiConfigurationService CiConfiguration => this; + + /// + public IGitEnvironmentService Environments => this; + + /// + public IGitBranchPolicyService BranchPolicies => this; + + /// + public IGitAccessControlService AccessControl => this; + + /// + public IGitWebhookService Webhooks => this; + + /// + public IGitWebhookIngestor WebhookIngestor => this; + + /// + public IGitNamespaceProvisioner NamespaceProvisioner => this; + + // ---- Test hooks ----------------------------------------------------- + + /// + /// Registers a platform-app installation so + /// and + /// can discover it. + /// + public void SeedInstallation(GitInstallationInfo installation) + { + ArgumentNullException.ThrowIfNull(installation); + lock (_gate) + { + _installations.RemoveAll(i => i.InstallationId == installation.InstallationId); + _installations.Add(installation); + _namespaces.Add(installation.AccountLogin); + } + } + + /// + /// Seeds an existing repository without going through the template flow. + /// + public void SeedRepository(GitRepositoryRef repository, bool @private = true) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + CreateRepoStateLocked(repository, @private); + } + } + + /// + /// Seeds a file path into a repository so reports it. + /// + public void SeedFile(GitRepositoryRef repository, string path) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (_repositories.TryGetValue(repository.FullName, out var state)) + { + state.Files.Add(path); + } + } + } + + /// + /// Transitions a pipeline run created by to a + /// terminal status. Returns false when the run is unknown. + /// + public bool CompleteRun(string runId, GitPipelineStatus status) + { + lock (_gate) + { + if (!_runs.TryGetValue(runId, out var run)) + { + return false; + } + + _runs[runId] = run with { Status = status }; + return true; + } + } + + // ---- IGitCredentialBroker ------------------------------------------- + + /// + public Task> MintAsync( + GitConnection connection, GitAccessTokenScope? scope = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + Log("Mint", connection.Provider); + return Task.FromResult(Result.Success(new GitAccessToken + { + Token = $"im_{Guid.NewGuid():N}", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + HttpBasicUsername = "x-access-token", + })); + } + + /// + public Task> ValidateAsync( + GitConnection connection, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + Log("Validate", connection.Provider); + + if (connection.Credential is GitCredential.AppInstallation app) + { + lock (_gate) + { + var install = _installations.FirstOrDefault(i => i.InstallationId == app.InstallationId); + return Task.FromResult(install is null + ? Result.Failure( + GitErrors.AuthenticationFailed(ProviderName, $"unknown installation '{app.InstallationId}'")) + : Result.Success(new GitConnectionIdentity(install.AccountLogin, install.AccountType))); + } + } + + return Task.FromResult(Result.Success(DefaultIdentity)); + } + + /// + public Task> ResolveAppInstallationAsync( + string @namespace, string? appKey = null, CancellationToken cancellationToken = default) + { + Log("ResolveAppInstallation", @namespace); + lock (_gate) + { + var install = _installations.FirstOrDefault( + i => string.Equals(i.AccountLogin, @namespace, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(install is null + ? Result.Failure( + GitErrors.AppNotInstalled(@namespace, "https://in-memory.invalid/install")) + : Result.Success(install)); + } + } + + /// + public Task>> ListAppInstallationsAsync( + string? appKey = null, CancellationToken cancellationToken = default) + { + Log("ListAppInstallations"); + lock (_gate) + { + return Task.FromResult(Result.Success>([.. _installations])); + } + } + + // ---- IGitRepositoryService ------------------------------------------ + + /// + public Task> CreateFromTemplateAsync( + GitConnection connection, CreateRepositoryFromTemplate request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + Log("CreateFromTemplate", request.Namespace, request.Name); + lock (_gate) + { + var reference = new GitRepositoryRef(request.Namespace, request.Name); + if (_repositories.ContainsKey(reference.FullName)) + { + return Task.FromResult(Result.Failure(GitErrors.Conflict(reference.FullName))); + } + + var state = CreateRepoStateLocked(reference, request.Private); + foreach (var file in InitialFiles) + { + state.Files.Add(file); + } + + return Task.FromResult(Result.Success(state.Info)); + } + } + + /// + public Task> GetAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + return Task.FromResult(_repositories.TryGetValue(repository.FullName, out var state) + ? Result.Success(state.Info) + : Result.Failure(GitErrors.RepositoryNotFound(repository.FullName))); + } + } + + /// + public Task>> ListAsync( + GitConnection connection, string @namespace, CancellationToken cancellationToken = default) + { + lock (_gate) + { + IReadOnlyList repos = _repositories.Values + .Where(s => string.Equals(s.Info.Ref.Namespace, @namespace, StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Info) + .ToList(); + return Task.FromResult(Result.Success(repos)); + } + } + + /// + public Task> FileExistsAsync( + GitConnection connection, GitRepositoryRef repository, string path, string? gitRef = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + return Task.FromResult(_repositories.TryGetValue(repository.FullName, out var state) + ? Result.Success(state.Files.Contains(path)) + : Result.Failure(GitErrors.RepositoryNotFound(repository.FullName))); + } + } + + /// + public Task>> ListCommitsAsync( + GitConnection connection, GitRepositoryRef repository, string? reference, int limit, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure>( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + IReadOnlyList commits = state.Commits.AsEnumerable().Reverse().Take(limit).ToList(); + return Task.FromResult(Result.Success(commits)); + } + } + + /// + public Task>> ListBranchesAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure>( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + IReadOnlyList branches = state.Branches + .Select(kvp => new GitBranch(kvp.Key, kvp.Value, Protected: state.Policies.ContainsKey(kvp.Key))) + .ToList(); + return Task.FromResult(Result.Success(branches)); + } + } + + /// + public Task> CreateTagAsync( + GitConnection connection, GitRepositoryRef repository, string tagName, string? commitSha = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + Log("CreateTag", repository.FullName, tagName); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure(GitErrors.RepositoryNotFound(repository.FullName))); + } + + var sha = commitSha ?? state.Branches[state.Info.DefaultBranch]; + var tag = new GitTag(tagName, sha); + state.Tags[tagName] = tag; + return Task.FromResult(Result.Success(tag)); + } + } + + /// + public Task>> ListTagsAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure>( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + IReadOnlyList tags = [.. state.Tags.Values]; + return Task.FromResult(Result.Success(tags)); + } + } + + /// + public Task> CreateReleaseAsync( + GitConnection connection, GitRepositoryRef repository, CreateGitRelease request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + Log("CreateRelease", repository.FullName, request.TagName); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure(GitErrors.RepositoryNotFound(repository.FullName))); + } + + if (!state.Tags.ContainsKey(request.TagName)) + { + var sha = request.TargetCommitSha ?? state.Branches[state.Info.DefaultBranch]; + state.Tags[request.TagName] = new GitTag(request.TagName, sha); + } + + var release = new GitRelease( + Id: $"rel-{++_nextReleaseId}", + TagName: request.TagName, + HtmlUrl: $"https://in-memory.invalid/{repository.FullName}/releases/{request.TagName}"); + state.Releases.Add(release); + return Task.FromResult(Result.Success(release)); + } + } + + // ---- IGitPipelineService -------------------------------------------- + + /// + public Task> TriggerAsync( + GitConnection connection, GitRepositoryRef repository, TriggerGitPipeline request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + Log("TriggerPipeline", repository.FullName, request.Pipeline, request.Reference); + lock (_gate) + { + if (!_repositories.ContainsKey(repository.FullName)) + { + return Task.FromResult(Result.Failure( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + var runId = $"run-{++_nextRunId}"; + _runs[runId] = new GitPipelineRun( + Id: runId, + Pipeline: request.Pipeline, + Status: GitPipelineStatus.Queued, + Reference: request.Reference, + HtmlUrl: $"https://in-memory.invalid/{repository.FullName}/runs/{runId}", + CreatedAt: DateTimeOffset.UtcNow); + return Task.FromResult(Result.Success(new GitPipelineRunHandle(runId))); + } + } + + /// + public Task> GetRunAsync( + GitConnection connection, GitRepositoryRef repository, string runId, + CancellationToken cancellationToken = default) + { + lock (_gate) + { + return Task.FromResult(_runs.TryGetValue(runId, out var run) + ? Result.Success(run) + : Result.Failure(Error.NotFound( + $"{GitErrors.Prefix}.RunNotFound", $"Pipeline run '{runId}' was not found."))); + } + } + + /// + public Task>> ListRunsAsync( + GitConnection connection, GitRepositoryRef repository, ListGitPipelineRuns query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + lock (_gate) + { + IReadOnlyList runs = _runs.Values + .Where(r => query.Pipeline is null || r.Pipeline == query.Pipeline) + .Where(r => query.Reference is null || r.Reference == query.Reference) + .OrderByDescending(r => r.CreatedAt) + .Take(query.Limit) + .ToList(); + return Task.FromResult(Result.Success(runs)); + } + } + + // ---- IGitCiConfigurationService ------------------------------------- + + /// + public Task SetSecretsAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary secrets, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(secrets); + Log("SetSecrets", ScopeKey(scope), string.Join(",", secrets.Keys)); + lock (_gate) + { + var names = GetOrAdd(_secretsByScope, ScopeKey(scope), () => new HashSet(StringComparer.Ordinal)); + foreach (var name in secrets.Keys) + { + names.Add(name); // values deliberately discarded: secrets are write-only + } + + return Task.FromResult(Result.Success()); + } + } + + /// + public Task DeleteSecretAsync( + GitConnection connection, GitConfigurationScope scope, string name, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + lock (_gate) + { + if (_secretsByScope.TryGetValue(ScopeKey(scope), out var names)) + { + names.Remove(name); + } + + return Task.FromResult(Result.Success()); + } + } + + /// + public Task SetVariablesAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary variables, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(variables); + Log("SetVariables", ScopeKey(scope), string.Join(",", variables.Keys)); + lock (_gate) + { + var store = GetOrAdd(_variablesByScope, ScopeKey(scope), () => new Dictionary(StringComparer.Ordinal)); + foreach (var (key, value) in variables) + { + store[key] = value; + } + + return Task.FromResult(Result.Success()); + } + } + + /// + public Task DeleteVariableAsync( + GitConnection connection, GitConfigurationScope scope, string name, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + lock (_gate) + { + if (_variablesByScope.TryGetValue(ScopeKey(scope), out var store)) + { + store.Remove(name); + } + + return Task.FromResult(Result.Success()); + } + } + + /// + /// Returns the secret NAMES stored at a scope (test-inspection hook — real + /// providers cannot read secrets back either). + /// + public IReadOnlyCollection GetSecretNames(GitConfigurationScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + lock (_gate) + { + return _secretsByScope.TryGetValue(ScopeKey(scope), out var names) ? [.. names] : []; + } + } + + /// + /// Returns the variables stored at a scope (test-inspection hook). + /// + public IReadOnlyDictionary GetVariables(GitConfigurationScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + lock (_gate) + { + return _variablesByScope.TryGetValue(ScopeKey(scope), out var store) + ? new Dictionary(store) + : new Dictionary(); + } + } + + // ---- IGitEnvironmentService ----------------------------------------- + + /// + public Task> EnsureAsync( + GitConnection connection, GitRepositoryRef repository, EnsureGitEnvironment request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + Log("EnsureEnvironment", repository.FullName, request.Name); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + var environment = new GitDeploymentEnvironment(request.Name); + state.Environments[request.Name] = environment; + return Task.FromResult(Result.Success(environment)); + } + } + + /// + public Task DeleteAsync( + GitConnection connection, GitRepositoryRef repository, string environmentName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (_repositories.TryGetValue(repository.FullName, out var state)) + { + state.Environments.Remove(environmentName); + } + + return Task.FromResult(Result.Success()); + } + } + + /// + Task>> IGitEnvironmentService.ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure>( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + IReadOnlyList environments = [.. state.Environments.Values]; + return Task.FromResult(Result.Success(environments)); + } + } + + // ---- IGitBranchPolicyService ---------------------------------------- + + /// + public Task> ApplyAsync( + GitConnection connection, GitRepositoryRef repository, GitBranchPolicyRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + Log("ApplyBranchPolicy", repository.FullName, request.Pattern); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + var policy = new GitBranchPolicy(Id: $"policy-{request.Pattern}", Pattern: request.Pattern); + state.Policies[request.Pattern] = policy; + return Task.FromResult(Result.Success(policy)); + } + } + + /// + public Task RemoveAsync( + GitConnection connection, GitRepositoryRef repository, string policyId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (_repositories.TryGetValue(repository.FullName, out var state)) + { + var pattern = state.Policies.Values.FirstOrDefault(p => p.Id == policyId)?.Pattern; + if (pattern is not null) + { + state.Policies.Remove(pattern); + } + } + + return Task.FromResult(Result.Success()); + } + } + + /// + Task>> IGitBranchPolicyService.ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure>( + GitErrors.RepositoryNotFound(repository.FullName))); + } + + IReadOnlyList policies = [.. state.Policies.Values]; + return Task.FromResult(Result.Success(policies)); + } + } + + // ---- IGitAccessControlService --------------------------------------- + + /// + public Task> EnsureTeamAsync( + GitConnection connection, string @namespace, EnsureGitTeam request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + Log("EnsureTeam", @namespace, request.Name); + lock (_gate) + { + var slug = request.Name.ToLowerInvariant().Replace(' ', '-'); + var teams = GetOrAdd(_teams, @namespace, () => new Dictionary(StringComparer.OrdinalIgnoreCase)); + var team = new GitTeam(slug, request.Name); + teams[slug] = team; + return Task.FromResult(Result.Success(team)); + } + } + + /// + public Task AddTeamMemberAsync( + GitConnection connection, string @namespace, string teamSlug, string username, GitTeamRole role, + CancellationToken cancellationToken = default) + => TeamScopedOperation(@namespace, teamSlug, $"AddTeamMember {username} {role}"); + + /// + public Task SetTeamRepositoryRoleAsync( + GitConnection connection, string @namespace, string teamSlug, GitRepositoryRef repository, + GitRepositoryRole role, CancellationToken cancellationToken = default) + => TeamScopedOperation(@namespace, teamSlug, $"SetTeamRepositoryRole {repository.FullName} {role}"); + + /// + public Task SetUserRepositoryRoleAsync( + GitConnection connection, GitRepositoryRef repository, string username, GitRepositoryRole role, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + Log("SetUserRepositoryRole", repository.FullName, username, role.ToString()); + lock (_gate) + { + if (!_repositories.TryGetValue(repository.FullName, out var state)) + { + return Task.FromResult(Result.Failure(GitErrors.RepositoryNotFound(repository.FullName))); + } + + state.Collaborators[username] = role; + return Task.FromResult(Result.Success()); + } + } + + /// + public Task RemoveUserFromRepositoryAsync( + GitConnection connection, GitRepositoryRef repository, string username, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + lock (_gate) + { + if (_repositories.TryGetValue(repository.FullName, out var state)) + { + state.Collaborators.Remove(username); + } + + return Task.FromResult(Result.Success()); + } + } + + // ---- IGitWebhookService --------------------------------------------- + + /// + public Task> EnsureAsync( + GitConnection connection, GitWebhookTarget target, EnsureGitWebhook request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(request); + Log("EnsureWebhook", TargetKey(target), request.Url.ToString()); + lock (_gate) + { + var store = GetWebhookStoreLocked(target); + var existing = store.Values.FirstOrDefault(s => s.Url == request.Url); + var subscription = new GitWebhookSubscription( + Id: existing?.Id ?? $"hook-{++_nextWebhookId}", + Url: request.Url, + Events: request.Events, + Active: request.Active); + store[subscription.Id] = subscription; + return Task.FromResult(Result.Success(subscription)); + } + } + + /// + public Task DeleteAsync( + GitConnection connection, GitWebhookTarget target, string subscriptionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + GetWebhookStoreLocked(target).Remove(subscriptionId); + return Task.FromResult(Result.Success()); + } + } + + /// + Task>> IGitWebhookService.ListAsync( + GitConnection connection, GitWebhookTarget target, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + IReadOnlyList subscriptions = [.. GetWebhookStoreLocked(target).Values]; + return Task.FromResult(Result.Success(subscriptions)); + } + } + + // ---- IGitWebhookIngestor -------------------------------------------- + + /// + public Result Parse(GitWebhookDelivery delivery, string secret) + { + ArgumentNullException.ThrowIfNull(delivery); + + if (!delivery.Headers.TryGetValue("X-InMemory-Signature", out var signature) + || !string.Equals(signature, secret, StringComparison.Ordinal)) + { + return Result.Failure(GitErrors.WebhookSignatureInvalid()); + } + + Envelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(delivery.Body, JsonOptions); + } + catch (JsonException) + { + return Result.Failure(Error.Validation( + $"{GitErrors.Prefix}.MalformedDelivery", "The webhook delivery body is not a valid envelope.")); + } + + if (envelope?.Type is null || envelope.DeliveryId is null) + { + return Result.Failure(Error.Validation( + $"{GitErrors.Prefix}.MalformedDelivery", "The webhook delivery body is not a valid envelope.")); + } + + var repository = envelope.Repository?.Split('/') is [var ns, var name] + ? new GitRepositoryRef(ns, name) + : null; + + GitWebhookEvent parsed = envelope.Type switch + { + "push" => new GitWebhookEvent.Push( + envelope.Reference ?? "refs/heads/main", + envelope.HeadCommitSha ?? string.Empty) + { DeliveryId = envelope.DeliveryId, Repository = repository }, + + "connection_changed" => new GitWebhookEvent.ConnectionChanged( + envelope.Namespace ?? string.Empty, + envelope.AccountType ?? GitAccountType.Organization, + envelope.InstallationId ?? string.Empty, + envelope.Change ?? GitConnectionChangeKind.Installed) + { DeliveryId = envelope.DeliveryId }, + + _ => new GitWebhookEvent.Unsupported(envelope.Type) + { DeliveryId = envelope.DeliveryId, Repository = repository }, + }; + + return Result.Success(parsed); + } + + // ---- IGitNamespaceProvisioner --------------------------------------- + + /// + public Task> CreateNamespaceAsync( + GitConnection connection, CreateGitNamespace request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + Log("CreateNamespace", request.Name); + lock (_gate) + { + if (!_namespaces.Add(request.Name)) + { + return Task.FromResult(Result.Failure(GitErrors.Conflict(request.Name))); + } + + return Task.FromResult(Result.Success( + new GitNamespace(request.Name, $"https://in-memory.invalid/{request.Name}"))); + } + } + + // ---- internals ------------------------------------------------------ + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + private sealed record Envelope( + string? Type, + string? DeliveryId, + string? Repository, + string? Reference, + string? HeadCommitSha, + string? Namespace, + GitAccountType? AccountType, + string? InstallationId, + GitConnectionChangeKind? Change); + + private sealed class RepoState + { + public required GitRepository Info { get; set; } + + public HashSet Files { get; } = new(StringComparer.Ordinal); + + public List Commits { get; } = []; + + public Dictionary Branches { get; } = new(StringComparer.Ordinal); + + public Dictionary Tags { get; } = new(StringComparer.Ordinal); + + public List Releases { get; } = []; + + public Dictionary Environments { get; } = new(StringComparer.Ordinal); + + public Dictionary Policies { get; } = new(StringComparer.Ordinal); + + public Dictionary Collaborators { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary Webhooks { get; } = new(StringComparer.Ordinal); + } + + private readonly Dictionary> _namespaceWebhooks = + new(StringComparer.OrdinalIgnoreCase); + + private RepoState CreateRepoStateLocked(GitRepositoryRef reference, bool @private) + { + _namespaces.Add(reference.Namespace); + var sha = Guid.NewGuid().ToString("N"); + var state = new RepoState + { + Info = new GitRepository( + Ref: reference, + CloneUrl: $"https://in-memory.invalid/{reference.FullName}.git", + HtmlUrl: $"https://in-memory.invalid/{reference.FullName}", + DefaultBranch: "main", + Private: @private), + }; + state.Branches["main"] = sha; + state.Commits.Add(new GitCommit( + Sha: sha, + Message: "Initial commit", + AuthorName: "in-memory", + AuthoredAt: DateTimeOffset.UtcNow, + HtmlUrl: $"https://in-memory.invalid/{reference.FullName}/commit/{sha}")); + _repositories[reference.FullName] = state; + return state; + } + + private Dictionary GetWebhookStoreLocked(GitWebhookTarget target) => + target switch + { + GitWebhookTarget.Repository r when _repositories.TryGetValue(r.Ref.FullName, out var state) => state.Webhooks, + GitWebhookTarget.Repository r => GetOrAdd( + _namespaceWebhooks, r.Ref.FullName, () => new Dictionary(StringComparer.Ordinal)), + GitWebhookTarget.Namespace n => GetOrAdd( + _namespaceWebhooks, n.Name, () => new Dictionary(StringComparer.Ordinal)), + _ => throw new InvalidOperationException($"Unknown webhook target: {target.GetType().Name}"), + }; + + private Task TeamScopedOperation(string @namespace, string teamSlug, string logEntry) + { + Log(logEntry, @namespace, teamSlug); + lock (_gate) + { + if (!_teams.TryGetValue(@namespace, out var teams) || !teams.ContainsKey(teamSlug)) + { + return Task.FromResult(Result.Failure(Error.NotFound( + $"{GitErrors.Prefix}.TeamNotFound", $"Team '{teamSlug}' was not found on '{@namespace}'."))); + } + + return Task.FromResult(Result.Success()); + } + } + + private static string ScopeKey(GitConfigurationScope scope) => scope switch + { + GitConfigurationScope.Repository r => $"repo:{r.Ref.FullName}", + GitConfigurationScope.Namespace n => $"ns:{n.Name}", + GitConfigurationScope.Environment e => $"env:{e.Ref.FullName}#{e.EnvironmentName}", + _ => throw new InvalidOperationException($"Unknown configuration scope: {scope.GetType().Name}"), + }; + + private static string TargetKey(GitWebhookTarget target) => target switch + { + GitWebhookTarget.Repository r => $"repo:{r.Ref.FullName}", + GitWebhookTarget.Namespace n => $"ns:{n.Name}", + _ => target.GetType().Name, + }; + + private static TValue GetOrAdd(Dictionary store, string key, Func factory) + { + if (!store.TryGetValue(key, out var value)) + { + value = factory(); + store[key] = value; + } + + return value; + } + + private void Log(params string[] parts) + { + lock (_gate) + { + _calls.Add(string.Join(' ', parts)); + } + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/Compendium.Abstractions.Git.Tests.csproj b/tests/Unit/Compendium.Abstractions.Git.Tests/Compendium.Abstractions.Git.Tests.csproj new file mode 100644 index 0000000..6b26d54 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/Compendium.Abstractions.Git.Tests.csproj @@ -0,0 +1,35 @@ + + + + Compendium.Abstractions.Git.Tests + Compendium.Abstractions.Git.Tests + false + true + enable + enable + false + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GlobalUsings.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GlobalUsings.cs new file mode 100644 index 0000000..e5b58cf --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GlobalUsings.cs @@ -0,0 +1,10 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; +global using FluentAssertions; +global using Xunit; diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerContractTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerContractTests.cs new file mode 100644 index 0000000..cfeaf12 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerContractTests.cs @@ -0,0 +1,65 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json; +using Compendium.Abstractions.Git; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using Compendium.Testing.Git; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Subscribes to the adapter contract — the +/// fake is the first executable proof of +/// and stays aligned with real adapters through it. +/// +public sealed class InMemoryGitServerContractTests : GitServerContractTests +{ + private readonly InMemoryGitServer _server; + + public InMemoryGitServerContractTests() + { + _server = new InMemoryGitServer(); + _server.SeedInstallation(new GitInstallationInfo( + InstallationId: "inst-1", + AccountLogin: "acme", + AccountType: GitAccountType.Organization)); + _server.SeedRepository(new GitRepositoryRef("platform", "template-dotnet")); + } + + protected override IGitServer Server => _server; + + protected override GitConnection Connection => new() + { + Provider = InMemoryGitServer.ProviderName, + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + protected override string Namespace => "acme"; + + protected override GitRepositoryRef TemplateRepository => new("platform", "template-dotnet"); + + protected override string WebhookSecret => InMemoryGitServer.WellKnownWebhookSecret; + + protected override GitWebhookDelivery CreateDelivery(bool validSignature) => new() + { + Body = JsonSerializer.Serialize(new + { + type = "push", + deliveryId = Guid.NewGuid().ToString(), + repository = "acme/some-repo", + reference = "refs/heads/main", + headCommitSha = "abc123", + }), + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-InMemory-Signature"] = validSignature ? WebhookSecret : "not-the-secret", + }, + }; +} From 08c83bde8c04abae5340b22190b658729b890987 Mon Sep 17 00:00:00 2001 From: sacha Date: Fri, 24 Jul 2026 10:45:26 +0200 Subject: [PATCH 2/4] test(git): unit-cover Compendium.Abstractions.Git to the 90% gate Claude-Session: https://claude.ai/code/session_01VFDn7gCH7vdvT4eoNhsMLr --- .../GitAccessTokenTests.cs | 94 +++ .../GitConnectionTests.cs | 84 ++ .../GitCredentialTests.cs | 121 +++ .../GitErrorsTests.cs | 216 +++++ .../GitPipelineModelsTests.cs | 99 +++ .../GitRepositoryModelsTests.cs | 173 ++++ .../GitRepositoryRefTests.cs | 55 ++ .../GitServerCapabilitiesTests.cs | 120 +++ .../GitServiceModelsTests.cs | 200 +++++ .../GitWebhookModelsTests.cs | 172 ++++ .../InMemoryGitServerTests.cs | 747 ++++++++++++++++++ .../NullGitServerTests.cs | 239 ++++++ 12 files changed, 2320 insertions(+) create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitAccessTokenTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitConnectionTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitCredentialTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitErrorsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitPipelineModelsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryModelsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryRefTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitServerCapabilitiesTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitServiceModelsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/GitWebhookModelsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Git.Tests/NullGitServerTests.cs diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitAccessTokenTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitAccessTokenTests.cs new file mode 100644 index 0000000..e1363f5 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitAccessTokenTests.cs @@ -0,0 +1,94 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for and +/// : a minted token must never leak its +/// material in ToString(), and the scope carries optional narrowing. +/// +public sealed class GitAccessTokenTests +{ + private const string Material = "im_ffffffffffffffffffffffffffffffff"; + + private static GitAccessToken Build() => new() + { + Token = Material, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + HttpBasicUsername = "x-access-token", + }; + + [Fact] + public void ToString_RedactsTheMaterialButKeepsExpiry() + { + // Arrange + var token = Build(); + + // Act + var text = token.ToString(); + + // Assert + text.Should().NotBeNullOrEmpty(); + text.Should().NotContain(Material); + text.Should().StartWith("GitAccessToken(***"); + text.Should().Contain("ExpiresAt="); + } + + [Fact] + public void Properties_RoundTripTheSuppliedValues() + { + // Arrange + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(45); + + // Act + var token = new GitAccessToken + { + Token = Material, + ExpiresAt = expiresAt, + HttpBasicUsername = "x-access-token", + }; + + // Assert + token.Token.Should().Be(Material); + token.ExpiresAt.Should().Be(expiresAt); + token.HttpBasicUsername.Should().Be("x-access-token"); + } + + [Fact] + public void Scope_DefaultsBothNarrowingMembersToNull() + { + // Arrange / Act + var scope = new GitAccessTokenScope(); + + // Assert + scope.Repositories.Should().BeNull(); + scope.Permissions.Should().BeNull(); + } + + [Fact] + public void Scope_CarriesRepositoriesAndPermissions() + { + // Arrange + var repositories = new[] { new GitRepositoryRef("acme", "billing") }; + var permissions = new Dictionary { ["contents"] = "write" }; + + // Act + var scope = new GitAccessTokenScope + { + Repositories = repositories, + Permissions = permissions, + }; + + // Assert + scope.Repositories.Should().ContainSingle().Which.Name.Should().Be("billing"); + scope.Permissions.Should().ContainKey("contents").WhoseValue.Should().Be("write"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitConnectionTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitConnectionTests.cs new file mode 100644 index 0000000..5d61b4f --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitConnectionTests.cs @@ -0,0 +1,84 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for : the stateless per-call handle +/// identifying which git server to talk to and with what credential. +/// +public sealed class GitConnectionTests +{ + [Fact] + public void ServerUrl_DefaultsToNull_TargetingProviderCloud() + { + // Arrange / Act + var connection = new GitConnection + { + Provider = "github", + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + // Assert + connection.Provider.Should().Be("github"); + connection.ServerUrl.Should().BeNull(); + } + + [Fact] + public void ServerUrl_WhenSet_TargetsTheSelfHostedInstance() + { + // Arrange + var serverUrl = new Uri("https://git.acme.invalid/api/v4"); + + // Act + var connection = new GitConnection + { + Provider = "gitlab", + ServerUrl = serverUrl, + Credential = new GitCredential.ServiceAccountToken("token"), + }; + + // Assert + connection.ServerUrl.Should().Be(serverUrl); + } + + [Fact] + public void Connections_WithSameValues_AreEqual() + { + // Arrange + var a = new GitConnection + { + Provider = "github", + Credential = new GitCredential.AppInstallation("inst-1"), + }; + var b = new GitConnection + { + Provider = "github", + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + // Act / Assert + a.Should().Be(b); + } + + [Fact] + public void Connections_DifferingOnlyByServerUrl_AreNotEqual() + { + // Arrange + var cloud = new GitConnection + { + Provider = "gitlab", + Credential = new GitCredential.ServiceAccountToken("token"), + }; + var selfHosted = cloud with { ServerUrl = new Uri("https://git.acme.invalid") }; + + // Act / Assert + selfHosted.Should().NotBe(cloud); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitCredentialTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitCredentialTests.cs new file mode 100644 index 0000000..2ab748e --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitCredentialTests.cs @@ -0,0 +1,121 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Connections; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for the closed union: token-bearing +/// members must redact their material in ToString() so a logged +/// connection never leaks a secret, and the value-record members compare by +/// value. +/// +public sealed class GitCredentialTests +{ + private const string Secret = "ghp_super_secret_token_value"; + + [Fact] + public void ServiceAccountToken_ToString_RedactsTheMaterial() + { + // Arrange + var credential = new GitCredential.ServiceAccountToken(Secret); + + // Act + var text = credential.ToString(); + + // Assert + text.Should().Be("ServiceAccountToken(***)"); + text.Should().NotBeNullOrEmpty(); + text.Should().NotContain(Secret); + } + + [Fact] + public void PersonalAccessToken_ToString_RedactsTheMaterial() + { + // Arrange + var credential = new GitCredential.PersonalAccessToken(Secret); + + // Act + var text = credential.ToString(); + + // Assert + text.Should().Be("PersonalAccessToken(***)"); + text.Should().NotBeNullOrEmpty(); + text.Should().NotContain(Secret); + } + + [Fact] + public void OAuthAccessToken_ToString_RedactsTheMaterial() + { + // Arrange + var credential = new GitCredential.OAuthAccessToken(Secret); + + // Act + var text = credential.ToString(); + + // Assert + text.Should().Be("OAuthAccessToken(***)"); + text.Should().NotBeNullOrEmpty(); + text.Should().NotContain(Secret); + } + + [Fact] + public void ServiceAccountToken_PreservesTheMaterialForUse() + { + // Arrange / Act + var credential = new GitCredential.ServiceAccountToken(Secret); + + // Assert + credential.Token.Should().Be(Secret); + } + + [Fact] + public void AppInstallation_DefaultsAppKeyToNull() + { + // Arrange / Act + var credential = new GitCredential.AppInstallation("inst-1"); + + // Assert + credential.InstallationId.Should().Be("inst-1"); + credential.AppKey.Should().BeNull(); + } + + [Fact] + public void AppInstallation_WithSameValues_AreEqual() + { + // Arrange + var a = new GitCredential.AppInstallation("inst-1", "app-key"); + var b = new GitCredential.AppInstallation("inst-1", "app-key"); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void AppInstallation_WithDifferentInstallationId_AreNotEqual() + { + // Arrange + var a = new GitCredential.AppInstallation("inst-1"); + var b = new GitCredential.AppInstallation("inst-2"); + + // Act / Assert + a.Should().NotBe(b); + } + + [Fact] + public void AppInstallation_DifferentSubtypeWithSameInstallationId_AreNotEqual() + { + // Arrange + GitCredential app = new GitCredential.AppInstallation("token"); + GitCredential token = new GitCredential.ServiceAccountToken("token"); + + // Act / Assert + app.Should().NotBe(token); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitErrorsTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitErrorsTests.cs new file mode 100644 index 0000000..b9ad237 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitErrorsTests.cs @@ -0,0 +1,216 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git; +using Compendium.Abstractions.Git.Capabilities; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for : every factory maps to the expected +/// machine code, , metadata, and message variant so +/// adapters and consumers can rely on a uniform failure surface. +/// +public sealed class GitErrorsTests +{ + [Fact] + public void Prefix_IsGit() + { + // Arrange / Act / Assert + GitErrors.Prefix.Should().Be("Git"); + } + + [Fact] + public void NotConfigured_WithoutProvider_MapsToUnavailableWithHostMessage() + { + // Arrange / Act + var error = GitErrors.NotConfigured(); + + // Assert + error.Code.Should().Be("Git.NotConfigured"); + error.Type.Should().Be(ErrorType.Unavailable); + error.Message.Should().Be("No git server is configured on this host."); + } + + [Fact] + public void NotConfigured_WithProvider_NamesTheProviderInTheMessage() + { + // Arrange / Act + var error = GitErrors.NotConfigured("github"); + + // Assert + error.Code.Should().Be("Git.NotConfigured"); + error.Type.Should().Be(ErrorType.Unavailable); + error.Message.Should().Be("The 'github' git server is not configured on this host."); + } + + [Fact] + public void NotSupported_WithoutLimitation_MapsToUnavailableWithProviderAndCapabilityMetadata() + { + // Arrange / Act + var error = GitErrors.NotSupported("gitea", GitCapability.BranchPolicies); + + // Assert + error.Code.Should().Be("Git.CapabilityNotSupported"); + error.Type.Should().Be(ErrorType.Unavailable); + error.Message.Should().Be("Provider 'gitea' does not support capability 'BranchPolicies'."); + error.Metadata.Should().ContainKey("provider").WhoseValue.Should().Be("gitea"); + error.Metadata.Should().ContainKey("capability").WhoseValue.Should().Be("BranchPolicies"); + error.Metadata.Should().NotContainKey("limitation"); + } + + [Fact] + public void NotSupported_WithLimitation_AppendsAndCapturesTheLimitation() + { + // Arrange / Act + var error = GitErrors.NotSupported("github", GitCapability.NamespaceProvisioning, "requires enterprise owner token"); + + // Assert + error.Code.Should().Be("Git.CapabilityNotSupported"); + error.Type.Should().Be(ErrorType.Unavailable); + error.Message.Should().Be( + "Provider 'github' does not support capability 'NamespaceProvisioning': requires enterprise owner token"); + error.Metadata.Should().ContainKey("limitation").WhoseValue.Should().Be("requires enterprise owner token"); + } + + [Fact] + public void AuthenticationFailed_WithoutDetail_MapsToUnauthorized() + { + // Arrange / Act + var error = GitErrors.AuthenticationFailed("gitlab"); + + // Assert + error.Code.Should().Be("Git.AuthenticationFailed"); + error.Type.Should().Be(ErrorType.Unauthorized); + error.Message.Should().Be("Authentication against 'gitlab' failed."); + } + + [Fact] + public void AuthenticationFailed_WithDetail_AppendsTheDetail() + { + // Arrange / Act + var error = GitErrors.AuthenticationFailed("gitlab", "token expired"); + + // Assert + error.Code.Should().Be("Git.AuthenticationFailed"); + error.Type.Should().Be(ErrorType.Unauthorized); + error.Message.Should().Be("Authentication against 'gitlab' failed: token expired"); + } + + [Fact] + public void AppNotInstalled_WithoutInstallUrl_MapsToFailureWithNamespaceMetadataOnly() + { + // Arrange / Act + var error = GitErrors.AppNotInstalled("acme"); + + // Assert + error.Code.Should().Be("Git.AppNotInstalled"); + error.Type.Should().Be(ErrorType.Failure); + error.Message.Should().Be("The platform app is not installed on 'acme'."); + error.Metadata.Should().ContainKey("namespace").WhoseValue.Should().Be("acme"); + error.Metadata.Should().NotContainKey("installUrl"); + } + + [Fact] + public void AppNotInstalled_WithInstallUrl_CarriesTheInstallUrlMetadata() + { + // Arrange / Act + var error = GitErrors.AppNotInstalled("acme", "https://example.invalid/install"); + + // Assert + error.Metadata.Should().ContainKey("namespace").WhoseValue.Should().Be("acme"); + error.Metadata.Should().ContainKey("installUrl").WhoseValue.Should().Be("https://example.invalid/install"); + } + + [Fact] + public void RepositoryNotFound_MapsToNotFound() + { + // Arrange / Act + var error = GitErrors.RepositoryNotFound("acme/billing"); + + // Assert + error.Code.Should().Be("Git.RepositoryNotFound"); + error.Type.Should().Be(ErrorType.NotFound); + error.Message.Should().Be("Repository 'acme/billing' was not found."); + } + + [Fact] + public void NamespaceNotFound_MapsToNotFound() + { + // Arrange / Act + var error = GitErrors.NamespaceNotFound("acme"); + + // Assert + error.Code.Should().Be("Git.NamespaceNotFound"); + error.Type.Should().Be(ErrorType.NotFound); + error.Message.Should().Be("Namespace 'acme' was not found."); + } + + [Fact] + public void Conflict_MapsToConflict() + { + // Arrange / Act + var error = GitErrors.Conflict("acme/billing"); + + // Assert + error.Code.Should().Be("Git.Conflict"); + error.Type.Should().Be(ErrorType.Conflict); + error.Message.Should().Be("'acme/billing' already exists."); + } + + [Fact] + public void Throttled_WithoutRetryAfter_MapsToTooManyRequestsWithoutMetadata() + { + // Arrange / Act + var error = GitErrors.Throttled(); + + // Assert + error.Code.Should().Be("Git.Throttled"); + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Message.Should().Be("Git provider throttled the request. Please try again later."); + error.Metadata.Should().NotContainKey("retryAfterSeconds"); + } + + [Fact] + public void Throttled_WithRetryAfter_CarriesRetryHintInMessageAndMetadata() + { + // Arrange / Act + var error = GitErrors.Throttled(TimeSpan.FromSeconds(30)); + + // Assert + error.Code.Should().Be("Git.Throttled"); + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Message.Should().Be("Git provider throttled the request. Retry after 30 seconds."); + error.Metadata.Should().ContainKey("retryAfterSeconds").WhoseValue.Should().Be(30d); + } + + [Fact] + public void WebhookSignatureInvalid_MapsToUnauthorized() + { + // Arrange / Act + var error = GitErrors.WebhookSignatureInvalid(); + + // Assert + error.Code.Should().Be("Git.WebhookSignatureInvalid"); + error.Type.Should().Be(ErrorType.Unauthorized); + error.Message.Should().Be("The webhook delivery signature is missing or invalid."); + } + + [Fact] + public void ProviderRejected_MapsToFailureWithProviderAndStatusCodeMetadata() + { + // Arrange / Act + var error = GitErrors.ProviderRejected("github", 422, "validation failed"); + + // Assert + error.Code.Should().Be("Git.ProviderRejected"); + error.Type.Should().Be(ErrorType.Failure); + error.Message.Should().Be("Provider 'github' rejected the request (422): validation failed"); + error.Metadata.Should().ContainKey("provider").WhoseValue.Should().Be("github"); + error.Metadata.Should().ContainKey("statusCode").WhoseValue.Should().Be(422); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitPipelineModelsTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitPipelineModelsTests.cs new file mode 100644 index 0000000..f3f1543 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitPipelineModelsTests.cs @@ -0,0 +1,99 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Pipelines; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for the pipeline DTO records: the trigger request, the +/// fire-and-forget run handle, the reported run, and the list filters. +/// +public sealed class GitPipelineModelsTests +{ + [Fact] + public void TriggerGitPipeline_DefaultsInputsToNull() + { + // Arrange / Act + var request = new TriggerGitPipeline { Pipeline = "bootstrap.yml", Reference = "main" }; + + // Assert + request.Pipeline.Should().Be("bootstrap.yml"); + request.Reference.Should().Be("main"); + request.Inputs.Should().BeNull(); + } + + [Fact] + public void TriggerGitPipeline_CarriesInputs() + { + // Arrange / Act + var request = new TriggerGitPipeline + { + Pipeline = "bootstrap.yml", + Reference = "main", + Inputs = new Dictionary { ["sha"] = "abc123" }, + }; + + // Assert + request.Inputs.Should().ContainKey("sha").WhoseValue.Should().Be("abc123"); + } + + [Fact] + public void GitPipelineRunHandle_AllowsNullRunIdForFireAndForgetProviders() + { + // Arrange / Act + var withId = new GitPipelineRunHandle("run-1"); + var withoutId = new GitPipelineRunHandle(RunId: null); + + // Assert + withId.RunId.Should().Be("run-1"); + withoutId.RunId.Should().BeNull(); + } + + [Fact] + public void GitPipelineRun_ProjectsAllProperties() + { + // Arrange + var createdAt = DateTimeOffset.UtcNow; + + // Act + var run = new GitPipelineRun("run-1", "bootstrap.yml", GitPipelineStatus.Running, "main", "https://x.invalid/runs/run-1", createdAt); + var (id, pipeline, status, reference, html, at) = run; + + // Assert + id.Should().Be("run-1"); + pipeline.Should().Be("bootstrap.yml"); + status.Should().Be(GitPipelineStatus.Running); + reference.Should().Be("main"); + html.Should().Be("https://x.invalid/runs/run-1"); + at.Should().Be(createdAt); + } + + [Fact] + public void ListGitPipelineRuns_DefaultsLimitTo20AndFiltersToNull() + { + // Arrange / Act + var query = new ListGitPipelineRuns(); + + // Assert + query.Pipeline.Should().BeNull(); + query.Reference.Should().BeNull(); + query.Limit.Should().Be(20); + } + + [Fact] + public void ListGitPipelineRuns_CarriesFiltersAndLimit() + { + // Arrange / Act + var query = new ListGitPipelineRuns { Pipeline = "build.yml", Reference = "main", Limit = 5 }; + + // Assert + query.Pipeline.Should().Be("build.yml"); + query.Reference.Should().Be("main"); + query.Limit.Should().Be(5); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryModelsTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryModelsTests.cs new file mode 100644 index 0000000..1397b6f --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryModelsTests.cs @@ -0,0 +1,173 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for the neutral repository DTO records in +/// GitRepositoryModels.cs: they are part of the public adapter contract, +/// so their construction, property projection, and value semantics are pinned. +/// +public sealed class GitRepositoryModelsTests +{ + private static readonly GitRepositoryRef Ref = new("acme", "billing"); + + [Fact] + public void GitRepository_ProjectsAllProperties() + { + // Arrange / Act + var repository = new GitRepository(Ref, "https://x.invalid/acme/billing.git", "https://x.invalid/acme/billing", "main", Private: true); + + // Assert + repository.Ref.Should().Be(Ref); + repository.CloneUrl.Should().Be("https://x.invalid/acme/billing.git"); + repository.HtmlUrl.Should().Be("https://x.invalid/acme/billing"); + repository.DefaultBranch.Should().Be("main"); + repository.Private.Should().BeTrue(); + repository.Should().Be(repository with { }); + } + + [Fact] + public void GitCommit_ProjectsAllPropertiesIncludingOptionalAuthorAndTimestamp() + { + // Arrange + var authoredAt = DateTimeOffset.UtcNow; + + // Act + var commit = new GitCommit("sha1", "message", "octocat", authoredAt, "https://x.invalid/c/sha1"); + var (sha, message, author, at, html) = commit; + + // Assert + sha.Should().Be("sha1"); + message.Should().Be("message"); + author.Should().Be("octocat"); + at.Should().Be(authoredAt); + html.Should().Be("https://x.invalid/c/sha1"); + commit.ToString().Should().Contain("sha1"); + } + + [Fact] + public void GitCommit_AllowsNullAuthorAndTimestamp() + { + // Arrange / Act + var commit = new GitCommit("sha1", "message", AuthorName: null, AuthoredAt: null, "https://x.invalid/c/sha1"); + + // Assert + commit.AuthorName.Should().BeNull(); + commit.AuthoredAt.Should().BeNull(); + } + + [Fact] + public void GitBranch_ProjectsAllProperties() + { + // Arrange / Act + var branch = new GitBranch("main", "sha1", Protected: true); + var (name, sha, isProtected) = branch; + + // Assert + name.Should().Be("main"); + sha.Should().Be("sha1"); + isProtected.Should().BeTrue(); + branch.Should().Be(new GitBranch("main", "sha1", true)); + } + + [Fact] + public void GitTag_ProjectsAllProperties() + { + // Arrange / Act + var tag = new GitTag("v1.0.0", "sha1"); + var (name, sha) = tag; + + // Assert + name.Should().Be("v1.0.0"); + sha.Should().Be("sha1"); + tag.Should().Be(new GitTag("v1.0.0", "sha1")); + } + + [Fact] + public void GitRelease_ProjectsAllProperties() + { + // Arrange / Act + var release = new GitRelease("rel-1", "v1.0.0", "https://x.invalid/releases/v1.0.0"); + var (id, tag, html) = release; + + // Assert + id.Should().Be("rel-1"); + tag.Should().Be("v1.0.0"); + html.Should().Be("https://x.invalid/releases/v1.0.0"); + } + + [Fact] + public void CreateRepositoryFromTemplate_DefaultsPrivateTrueAndDescriptionNull() + { + // Arrange / Act + var request = new CreateRepositoryFromTemplate + { + Template = Ref, + Namespace = "acme", + Name = "new-service", + }; + + // Assert + request.Template.Should().Be(Ref); + request.Namespace.Should().Be("acme"); + request.Name.Should().Be("new-service"); + request.Description.Should().BeNull(); + request.Private.Should().BeTrue(); + } + + [Fact] + public void CreateRepositoryFromTemplate_CarriesDescriptionAndPrivacyOverride() + { + // Arrange / Act + var request = new CreateRepositoryFromTemplate + { + Template = Ref, + Namespace = "acme", + Name = "public-docs", + Description = "the docs", + Private = false, + }; + + // Assert + request.Description.Should().Be("the docs"); + request.Private.Should().BeFalse(); + } + + [Fact] + public void CreateGitRelease_DefaultsOptionalMembersToNull() + { + // Arrange / Act + var request = new CreateGitRelease { TagName = "v1.0.0" }; + + // Assert + request.TagName.Should().Be("v1.0.0"); + request.TargetCommitSha.Should().BeNull(); + request.Title.Should().BeNull(); + request.Body.Should().BeNull(); + } + + [Fact] + public void CreateGitRelease_CarriesAllOptionalMembers() + { + // Arrange / Act + var request = new CreateGitRelease + { + TagName = "v1.0.0", + TargetCommitSha = "sha1", + Title = "Release 1.0.0", + Body = "notes", + }; + + // Assert + request.TargetCommitSha.Should().Be("sha1"); + request.Title.Should().Be("Release 1.0.0"); + request.Body.Should().Be("notes"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryRefTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryRefTests.cs new file mode 100644 index 0000000..090c4cb --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitRepositoryRefTests.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for : the namespace/name +/// identifier used throughout the abstraction. +/// +public sealed class GitRepositoryRefTests +{ + [Fact] + public void FullName_JoinsNamespaceAndNameWithASlash() + { + // Arrange + var reference = new GitRepositoryRef("acme", "billing-api"); + + // Act + var fullName = reference.FullName; + + // Assert + fullName.Should().Be("acme/billing-api"); + } + + [Fact] + public void ToString_ReturnsTheFullName() + { + // Arrange + var reference = new GitRepositoryRef("acme", "billing-api"); + + // Act + var text = reference.ToString(); + + // Assert + text.Should().Be("acme/billing-api"); + } + + [Fact] + public void References_WithSameNamespaceAndName_AreEqual() + { + // Arrange + var a = new GitRepositoryRef("acme", "billing-api"); + var b = new GitRepositoryRef("acme", "billing-api"); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitServerCapabilitiesTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitServerCapabilitiesTests.cs new file mode 100644 index 0000000..992451b --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitServerCapabilitiesTests.cs @@ -0,0 +1,120 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Capabilities; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for : the capability matrix +/// drives UI affordances via and +/// uniform failures via . +/// +public sealed class GitServerCapabilitiesTests +{ + private static GitServerCapabilities Build( + IReadOnlyDictionary entries) => new() + { + Provider = "github", + Entries = entries, + }; + + [Theory] + [InlineData(GitCapabilityLevel.Full, true)] + [InlineData(GitCapabilityLevel.Partial, true)] + [InlineData(GitCapabilityLevel.None, false)] + public void Supports_PresentEntry_ReflectsTheDeclaredLevel(GitCapabilityLevel level, bool expected) + { + // Arrange + var capabilities = Build(new Dictionary + { + [GitCapability.RepositoryManagement] = new(level), + }); + + // Act + var supported = capabilities.Supports(GitCapability.RepositoryManagement); + + // Assert + supported.Should().Be(expected); + } + + [Fact] + public void Supports_AbsentEntry_ReturnsFalse() + { + // Arrange + var capabilities = Build(new Dictionary()); + + // Act + var supported = capabilities.Supports(GitCapability.PipelineTrigger); + + // Assert + supported.Should().BeFalse(); + } + + [Fact] + public void EnsureSupported_SupportedCapability_ReturnsSuccess() + { + // Arrange + var capabilities = Build(new Dictionary + { + [GitCapability.CiSecrets] = new(GitCapabilityLevel.Full), + }); + + // Act + var result = capabilities.EnsureSupported(GitCapability.CiSecrets); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void EnsureSupported_AbsentCapability_FailsWithoutLimitation() + { + // Arrange + var capabilities = Build(new Dictionary()); + + // Act + var result = capabilities.EnsureSupported(GitCapability.WebhookIngestion); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.CapabilityNotSupported"); + result.Error.Metadata.Should().ContainKey("provider").WhoseValue.Should().Be("github"); + result.Error.Metadata.Should().ContainKey("capability").WhoseValue.Should().Be("WebhookIngestion"); + result.Error.Metadata.Should().NotContainKey("limitation"); + } + + [Fact] + public void EnsureSupported_NoneLevelWithLimitation_FailsAndPropagatesTheLimitation() + { + // Arrange + var capabilities = Build(new Dictionary + { + [GitCapability.NamespaceProvisioning] = new(GitCapabilityLevel.None, "org creation not reachable"), + }); + + // Act + var result = capabilities.EnsureSupported(GitCapability.NamespaceProvisioning); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.CapabilityNotSupported"); + result.Error.Message.Should().Contain("org creation not reachable"); + result.Error.Metadata.Should().ContainKey("limitation").WhoseValue.Should().Be("org creation not reachable"); + } + + [Fact] + public void GitCapabilitySupport_DefaultsLimitationToNull() + { + // Arrange / Act + var support = new GitCapabilitySupport(GitCapabilityLevel.Full); + + // Assert + support.Level.Should().Be(GitCapabilityLevel.Full); + support.Limitation.Should().BeNull(); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitServiceModelsTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitServiceModelsTests.cs new file mode 100644 index 0000000..fa52d35 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitServiceModelsTests.cs @@ -0,0 +1,200 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for the configuration-scope union and the per-service request and +/// result DTO records, pinning their default values and optional members. +/// +public sealed class GitServiceModelsTests +{ + private static readonly GitRepositoryRef Ref = new("acme", "billing"); + + [Fact] + public void GitConfigurationScope_RepositoryVariant_ProjectsRepositoryRef() + { + // Arrange / Act + var scope = new GitConfigurationScope.Repository(Ref); + + // Assert + scope.Ref.Should().Be(Ref); + } + + [Fact] + public void GitConfigurationScope_NamespaceVariant_ProjectsName() + { + // Arrange / Act + var scope = new GitConfigurationScope.Namespace("acme"); + + // Assert + scope.Name.Should().Be("acme"); + } + + [Fact] + public void GitConfigurationScope_EnvironmentVariant_ProjectsRefAndEnvironmentName() + { + // Arrange / Act + var scope = new GitConfigurationScope.Environment(Ref, "production"); + + // Assert + scope.Ref.Should().Be(Ref); + scope.EnvironmentName.Should().Be("production"); + } + + [Fact] + public void EnsureGitEnvironment_ProjectsName() + { + // Arrange / Act + var request = new EnsureGitEnvironment { Name = "production" }; + + // Assert + request.Name.Should().Be("production"); + } + + [Fact] + public void GitDeploymentEnvironment_DefaultsHtmlUrlToNull() + { + // Arrange / Act + var withUrl = new GitDeploymentEnvironment("production", "https://x.invalid/envs/production"); + var withoutUrl = new GitDeploymentEnvironment("staging"); + + // Assert + withUrl.Name.Should().Be("production"); + withUrl.HtmlUrl.Should().Be("https://x.invalid/envs/production"); + withoutUrl.HtmlUrl.Should().BeNull(); + } + + [Fact] + public void GitBranchPolicyRequest_DefaultsMatchTheSafeProtectionProfile() + { + // Arrange / Act + var request = new GitBranchPolicyRequest { Pattern = "main" }; + + // Assert + request.Pattern.Should().Be("main"); + request.RequirePullRequest.Should().BeTrue(); + request.RequiredApprovals.Should().Be(0); + request.DismissStaleApprovals.Should().BeFalse(); + request.RequiredStatusChecks.Should().BeNull(); + request.BlockForcePush.Should().BeTrue(); + request.BlockDeletion.Should().BeTrue(); + request.RequireLinearHistory.Should().BeFalse(); + request.EnforceForAdmins.Should().BeFalse(); + } + + [Fact] + public void GitBranchPolicyRequest_CarriesEveryOverride() + { + // Arrange / Act + var request = new GitBranchPolicyRequest + { + Pattern = "release/*", + RequirePullRequest = false, + RequiredApprovals = 2, + DismissStaleApprovals = true, + RequiredStatusChecks = ["build", "test"], + BlockForcePush = false, + BlockDeletion = false, + RequireLinearHistory = true, + EnforceForAdmins = true, + }; + + // Assert + request.RequiredApprovals.Should().Be(2); + request.DismissStaleApprovals.Should().BeTrue(); + request.RequiredStatusChecks.Should().BeEquivalentTo("build", "test"); + request.RequireLinearHistory.Should().BeTrue(); + request.EnforceForAdmins.Should().BeTrue(); + } + + [Fact] + public void GitBranchPolicy_ProjectsIdAndPattern() + { + // Arrange / Act + var policy = new GitBranchPolicy("policy-main", "main"); + + // Assert + policy.Id.Should().Be("policy-main"); + policy.Pattern.Should().Be("main"); + } + + [Fact] + public void EnsureGitTeam_DefaultsDescriptionToNull_AndCarriesItWhenSet() + { + // Arrange / Act + var minimal = new EnsureGitTeam { Name = "Platform" }; + var described = new EnsureGitTeam { Name = "Platform", Description = "owns the platform" }; + + // Assert + minimal.Name.Should().Be("Platform"); + minimal.Description.Should().BeNull(); + described.Description.Should().Be("owns the platform"); + } + + [Fact] + public void GitTeam_ProjectsSlugAndName() + { + // Arrange / Act + var team = new GitTeam("platform", "Platform"); + + // Assert + team.Slug.Should().Be("platform"); + team.Name.Should().Be("Platform"); + } + + [Fact] + public void CreateGitNamespace_DefaultsOptionalMembersToNull() + { + // Arrange / Act + var request = new CreateGitNamespace { Name = "NXS-Acme" }; + + // Assert + request.Name.Should().Be("NXS-Acme"); + request.DisplayName.Should().BeNull(); + request.BillingEmail.Should().BeNull(); + request.AdminLogins.Should().BeNull(); + } + + [Fact] + public void CreateGitNamespace_CarriesEveryOptionalMember() + { + // Arrange / Act + var request = new CreateGitNamespace + { + Name = "NXS-Acme", + DisplayName = "Acme Inc.", + BillingEmail = "billing@acme.invalid", + AdminLogins = ["octocat"], + }; + + // Assert + request.DisplayName.Should().Be("Acme Inc."); + request.BillingEmail.Should().Be("billing@acme.invalid"); + request.AdminLogins.Should().ContainSingle().Which.Should().Be("octocat"); + } + + [Fact] + public void GitNamespace_DefaultsHtmlUrlToNull() + { + // Arrange / Act + var withUrl = new GitNamespace("NXS-Acme", "https://x.invalid/NXS-Acme"); + var withoutUrl = new GitNamespace("NXS-Acme"); + + // Assert + withUrl.Name.Should().Be("NXS-Acme"); + withUrl.HtmlUrl.Should().Be("https://x.invalid/NXS-Acme"); + withoutUrl.HtmlUrl.Should().BeNull(); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/GitWebhookModelsTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/GitWebhookModelsTests.cs new file mode 100644 index 0000000..4bf7a55 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/GitWebhookModelsTests.cs @@ -0,0 +1,172 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using Compendium.Abstractions.Git.Connections; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for the inbound/outbound webhook DTO records: the neutral event +/// union, the raw delivery, the subscription target union, and the request +/// record whose ToString() must redact its shared secret. +/// +public sealed class GitWebhookModelsTests +{ + private static readonly GitRepositoryRef Ref = new("acme", "billing"); + + [Fact] + public void Push_CarriesReferenceHeadAndBaseMembers() + { + // Arrange / Act + var push = new GitWebhookEvent.Push("refs/heads/main", "sha1") { DeliveryId = "d-1", Repository = Ref }; + + // Assert + push.Reference.Should().Be("refs/heads/main"); + push.HeadCommitSha.Should().Be("sha1"); + push.DeliveryId.Should().Be("d-1"); + push.Repository.Should().Be(Ref); + } + + [Fact] + public void TagPushed_CarriesTagAndCommit() + { + // Arrange / Act + var tagPushed = new GitWebhookEvent.TagPushed("v1.0.0", "sha1") { DeliveryId = "d-2" }; + + // Assert + tagPushed.Tag.Should().Be("v1.0.0"); + tagPushed.CommitSha.Should().Be("sha1"); + tagPushed.DeliveryId.Should().Be("d-2"); + tagPushed.Repository.Should().BeNull(); + } + + [Fact] + public void PullRequestChanged_CarriesActionNumberAndBranches() + { + // Arrange / Act + var pr = new GitWebhookEvent.PullRequestChanged("opened", 42, "feature/x", "main") { DeliveryId = "d-3" }; + + // Assert + pr.Action.Should().Be("opened"); + pr.Number.Should().Be(42); + pr.SourceReference.Should().Be("feature/x"); + pr.TargetReference.Should().Be("main"); + pr.DeliveryId.Should().Be("d-3"); + } + + [Fact] + public void PipelineRunCompleted_CarriesRunPipelineStatusAndReference() + { + // Arrange / Act + var completed = new GitWebhookEvent.PipelineRunCompleted("run-1", "build.yml", GitPipelineStatus.Succeeded, "main") + { + DeliveryId = "d-4", + }; + + // Assert + completed.RunId.Should().Be("run-1"); + completed.Pipeline.Should().Be("build.yml"); + completed.Status.Should().Be(GitPipelineStatus.Succeeded); + completed.Reference.Should().Be("main"); + } + + [Fact] + public void ConnectionChanged_CarriesInstallationFields() + { + // Arrange / Act + var changed = new GitWebhookEvent.ConnectionChanged("acme", GitAccountType.Organization, "inst-1", GitConnectionChangeKind.Uninstalled) + { + DeliveryId = "d-5", + }; + + // Assert + changed.Namespace.Should().Be("acme"); + changed.AccountType.Should().Be(GitAccountType.Organization); + changed.InstallationId.Should().Be("inst-1"); + changed.Change.Should().Be(GitConnectionChangeKind.Uninstalled); + } + + [Fact] + public void Unsupported_CarriesProviderEventType() + { + // Arrange / Act + var unsupported = new GitWebhookEvent.Unsupported("issues") { DeliveryId = "d-6" }; + + // Assert + unsupported.ProviderEventType.Should().Be("issues"); + unsupported.DeliveryId.Should().Be("d-6"); + } + + [Fact] + public void GitWebhookDelivery_ProjectsBodyAndHeaders() + { + // Arrange / Act + var delivery = new GitWebhookDelivery + { + Body = "{}", + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["X-Sig"] = "v" }, + }; + + // Assert + delivery.Body.Should().Be("{}"); + delivery.Headers.Should().ContainKey("x-sig").WhoseValue.Should().Be("v"); + } + + [Fact] + public void WebhookTarget_RepositoryAndNamespaceVariantsProjectTheirValues() + { + // Arrange / Act + var repoTarget = new GitWebhookTarget.Repository(Ref); + var namespaceTarget = new GitWebhookTarget.Namespace("acme"); + + // Assert + repoTarget.Ref.Should().Be(Ref); + namespaceTarget.Name.Should().Be("acme"); + } + + [Fact] + public void EnsureGitWebhook_DefaultsActiveTrue_AndRedactsSecretInToString() + { + // Arrange + var request = new EnsureGitWebhook + { + Url = new Uri("https://platform.invalid/webhooks/git"), + Secret = "top-secret-signing-key", + Events = ["push", "workflow_run"], + }; + + // Act + var text = request.ToString(); + + // Assert + request.Active.Should().BeTrue(); + request.Events.Should().BeEquivalentTo("push", "workflow_run"); + text.Should().Contain("Secret=***"); + text.Should().NotContain("top-secret-signing-key"); + text.Should().Contain("push"); + } + + [Fact] + public void GitWebhookSubscription_ProjectsAllProperties() + { + // Arrange + var url = new Uri("https://platform.invalid/webhooks/git"); + + // Act + var subscription = new GitWebhookSubscription("hook-1", url, ["push"], Active: false); + var (id, subUrl, events, active) = subscription; + + // Assert + id.Should().Be("hook-1"); + subUrl.Should().Be(url); + events.Should().ContainSingle().Which.Should().Be("push"); + active.Should().BeFalse(); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerTests.cs new file mode 100644 index 0000000..50bc041 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/InMemoryGitServerTests.cs @@ -0,0 +1,747 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json; +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using Compendium.Testing.Git; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for behaviors of not already +/// exercised by : seeding hooks, +/// write-only secret semantics, run transitions, tag/release/commit reads, +/// installation discovery, the call log, and inbound webhook parsing branches. +/// +public sealed class InMemoryGitServerTests +{ + private readonly InMemoryGitServer _server = new(); + + private static GitConnection TokenConnection => new() + { + Provider = InMemoryGitServer.ProviderName, + Credential = new GitCredential.ServiceAccountToken("token"), + }; + + private static GitRepositoryRef Repo => new("acme", "billing"); + + // ---- seeding & repository reads ------------------------------------- + + [Fact] + public async Task SeedFile_ThenFileExists_ReturnsTrueForSeededPathOnly() + { + // Arrange + _server.SeedRepository(Repo); + _server.SeedFile(Repo, "docs/README.md"); + + // Act + var seeded = await _server.Repositories.FileExistsAsync(TokenConnection, Repo, "docs/README.md"); + var absent = await _server.Repositories.FileExistsAsync(TokenConnection, Repo, "docs/MISSING.md"); + + // Assert + seeded.IsSuccess.Should().BeTrue(); + seeded.Value.Should().BeTrue(); + absent.Value.Should().BeFalse(); + } + + [Fact] + public async Task CreateFromTemplate_SeedsBootstrappedMarkerFromInitialFiles() + { + // Arrange + var request = new CreateRepositoryFromTemplate + { + Template = new GitRepositoryRef("platform", "template-dotnet"), + Namespace = "acme", + Name = "new-service", + }; + + // Act + var created = await _server.Repositories.CreateFromTemplateAsync(TokenConnection, request); + var exists = await _server.Repositories.FileExistsAsync(TokenConnection, created.Value.Ref, ".bootstrapped"); + + // Assert + created.IsSuccess.Should().BeTrue(); + exists.Value.Should().BeTrue(".bootstrapped is seeded from InitialFiles on every template creation"); + } + + [Fact] + public async Task ListCommits_ReturnsSeededCommitNewestFirst_AndRespectsLimit() + { + // Arrange + _server.SeedRepository(Repo); + + // Act + var all = await _server.Repositories.ListCommitsAsync(TokenConnection, Repo, reference: null, limit: 10); + var none = await _server.Repositories.ListCommitsAsync(TokenConnection, Repo, reference: null, limit: 0); + + // Assert + all.IsSuccess.Should().BeTrue(); + all.Value.Should().ContainSingle().Which.Message.Should().Be("Initial commit"); + none.Value.Should().BeEmpty("a zero limit yields no commits"); + } + + [Fact] + public async Task ListCommits_OnMissingRepository_FailsRepositoryNotFound() + { + // Arrange / Act + var result = await _server.Repositories.ListCommitsAsync(TokenConnection, Repo, reference: null, limit: 5); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task CreateTag_OnMissingRepository_FailsRepositoryNotFound() + { + // Arrange / Act + var result = await _server.Repositories.CreateTagAsync(TokenConnection, Repo, "v1.0.0"); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task CreateRelease_WhenTagAbsent_CreatesTheTag() + { + // Arrange + _server.SeedRepository(Repo); + + // Act + var release = await _server.Repositories.CreateReleaseAsync( + TokenConnection, Repo, new CreateGitRelease { TagName = "v2.0.0" }); + var tags = await _server.Repositories.ListTagsAsync(TokenConnection, Repo); + + // Assert + release.IsSuccess.Should().BeTrue(); + release.Value.TagName.Should().Be("v2.0.0"); + tags.Value.Should().ContainSingle(t => t.Name == "v2.0.0"); + } + + // ---- CI configuration: write-only secrets, readable variables ------- + + [Fact] + public async Task GetSecretNames_ReturnsNamesOnlyAfterSetSecrets() + { + // Arrange + _server.SeedRepository(Repo); + var scope = new GitConfigurationScope.Repository(Repo); + _server.GetSecretNames(scope).Should().BeEmpty("no secrets set yet"); + + // Act + await _server.CiConfiguration.SetSecretsAsync( + TokenConnection, scope, new Dictionary { ["API_KEY"] = "s3cret", ["DB_PASS"] = "hunter2" }); + var names = _server.GetSecretNames(scope); + + // Assert + names.Should().BeEquivalentTo("API_KEY", "DB_PASS"); + names.Should().NotContain("s3cret", "only names are retained; secret values are write-only"); + } + + [Fact] + public async Task GetVariables_RoundTripsValues() + { + // Arrange + _server.SeedRepository(Repo); + var scope = new GitConfigurationScope.Repository(Repo); + + // Act + await _server.CiConfiguration.SetVariablesAsync( + TokenConnection, scope, new Dictionary { ["LOG_LEVEL"] = "debug" }); + var variables = _server.GetVariables(scope); + + // Assert + variables.Should().ContainKey("LOG_LEVEL").WhoseValue.Should().Be("debug"); + } + + [Fact] + public void GetVariables_ForUnknownScope_ReturnsEmpty() + { + // Arrange + var scope = new GitConfigurationScope.Namespace("nobody"); + + // Act + var variables = _server.GetVariables(scope); + + // Assert + variables.Should().BeEmpty(); + } + + // ---- pipelines ------------------------------------------------------ + + [Fact] + public async Task CompleteRun_TransitionsStatus_AndReturnsFalseForUnknownRun() + { + // Arrange + _server.SeedRepository(Repo); + var triggered = await _server.Pipelines.TriggerAsync( + TokenConnection, Repo, new TriggerGitPipeline { Pipeline = "bootstrap.yml", Reference = "main" }); + var runId = triggered.Value.RunId!; + + // Act + var transitioned = _server.CompleteRun(runId, GitPipelineStatus.Succeeded); + var unknown = _server.CompleteRun("run-does-not-exist", GitPipelineStatus.Failed); + var run = await _server.Pipelines.GetRunAsync(TokenConnection, Repo, runId); + + // Assert + transitioned.Should().BeTrue(); + unknown.Should().BeFalse(); + run.IsSuccess.Should().BeTrue(); + run.Value.Status.Should().Be(GitPipelineStatus.Succeeded); + } + + [Fact] + public async Task GetRun_ForUnknownRun_FailsRunNotFound() + { + // Arrange / Act + var result = await _server.Pipelines.GetRunAsync(TokenConnection, Repo, "run-404"); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.RunNotFound"); + } + + [Fact] + public async Task ListRuns_FiltersByPipelineAndReference() + { + // Arrange + _server.SeedRepository(Repo); + await _server.Pipelines.TriggerAsync( + TokenConnection, Repo, new TriggerGitPipeline { Pipeline = "build.yml", Reference = "main" }); + await _server.Pipelines.TriggerAsync( + TokenConnection, Repo, new TriggerGitPipeline { Pipeline = "deploy.yml", Reference = "release" }); + + // Act + var filtered = await _server.Pipelines.ListRunsAsync( + TokenConnection, Repo, new ListGitPipelineRuns { Pipeline = "build.yml" }); + + // Assert + filtered.IsSuccess.Should().BeTrue(); + filtered.Value.Should().ContainSingle().Which.Pipeline.Should().Be("build.yml"); + } + + // ---- access control ------------------------------------------------- + + [Fact] + public async Task AddTeamMember_OnUnknownTeam_FailsTeamNotFound() + { + // Arrange / Act + var result = await _server.AccessControl.AddTeamMemberAsync( + TokenConnection, "acme", "ghost-team", "octocat", GitTeamRole.Member); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.TeamNotFound"); + } + + [Fact] + public async Task EnsureTeam_ThenAddTeamMember_Succeeds() + { + // Arrange + var team = await _server.AccessControl.EnsureTeamAsync( + TokenConnection, "acme", new EnsureGitTeam { Name = "Platform Team" }); + + // Act + var added = await _server.AccessControl.AddTeamMemberAsync( + TokenConnection, "acme", team.Value.Slug, "octocat", GitTeamRole.Maintainer); + + // Assert + team.Value.Slug.Should().Be("platform-team"); + added.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task SetUserRepositoryRole_OnMissingRepository_FailsRepositoryNotFound() + { + // Arrange / Act + var result = await _server.AccessControl.SetUserRepositoryRoleAsync( + TokenConnection, Repo, "octocat", GitRepositoryRole.Write); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + // ---- credential broker ---------------------------------------------- + + [Fact] + public async Task ResolveAppInstallation_WhenAbsent_FailsAppNotInstalledWithInstallUrl() + { + // Arrange / Act + var result = await _server.Credentials.ResolveAppInstallationAsync("nobody"); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.AppNotInstalled"); + result.Error.Metadata.Should().ContainKey("installUrl"); + } + + [Fact] + public async Task ResolveAppInstallation_WhenSeeded_ReturnsInstallation() + { + // Arrange + _server.SeedInstallation(new GitInstallationInfo("inst-1", "acme", GitAccountType.Organization)); + + // Act + var result = await _server.Credentials.ResolveAppInstallationAsync("acme"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.InstallationId.Should().Be("inst-1"); + } + + [Fact] + public async Task ListAppInstallations_ReturnsSeededInstallations() + { + // Arrange + _server.SeedInstallation(new GitInstallationInfo("inst-1", "acme", GitAccountType.Organization)); + _server.SeedInstallation(new GitInstallationInfo("inst-2", "globex", GitAccountType.User)); + + // Act + var result = await _server.Credentials.ListAppInstallationsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Select(i => i.InstallationId).Should().BeEquivalentTo("inst-1", "inst-2"); + } + + [Fact] + public async Task Validate_WithUnknownAppInstallation_FailsAuthenticationFailed() + { + // Arrange + var connection = new GitConnection + { + Provider = InMemoryGitServer.ProviderName, + Credential = new GitCredential.AppInstallation("ghost"), + }; + + // Act + var result = await _server.Credentials.ValidateAsync(connection); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.AuthenticationFailed"); + } + + [Fact] + public async Task Validate_WithSeededAppInstallation_ReportsInstallationIdentity() + { + // Arrange + _server.SeedInstallation(new GitInstallationInfo("inst-1", "acme", GitAccountType.Organization)); + var connection = new GitConnection + { + Provider = InMemoryGitServer.ProviderName, + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + // Act + var result = await _server.Credentials.ValidateAsync(connection); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.AccountLogin.Should().Be("acme"); + result.Value.AccountType.Should().Be(GitAccountType.Organization); + } + + // ---- namespace provisioning ----------------------------------------- + + [Fact] + public async Task CreateNamespace_OnDuplicate_FailsConflict() + { + // Arrange + var first = await _server.NamespaceProvisioner.CreateNamespaceAsync( + TokenConnection, new CreateGitNamespace { Name = "NXS-Acme" }); + + // Act + var second = await _server.NamespaceProvisioner.CreateNamespaceAsync( + TokenConnection, new CreateGitNamespace { Name = "NXS-Acme" }); + + // Assert + first.IsSuccess.Should().BeTrue(); + second.IsFailure.Should().BeTrue(); + second.Error.Code.Should().Be("Git.Conflict"); + } + + // ---- inbound webhook parsing ---------------------------------------- + + [Fact] + public void Parse_MissingSignatureHeader_IsRejected() + { + // Arrange + var delivery = new GitWebhookDelivery + { + Body = "{\"type\":\"push\",\"deliveryId\":\"d-1\"}", + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.WebhookSignatureInvalid"); + } + + [Fact] + public void Parse_MalformedJsonBody_FailsMalformedDelivery() + { + // Arrange + var delivery = SignedDelivery("{ this is not json"); + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.MalformedDelivery"); + result.Error.Type.Should().Be(ErrorType.Validation); + } + + [Fact] + public void Parse_EnvelopeMissingType_FailsMalformedDelivery() + { + // Arrange + var delivery = SignedDelivery("{}"); + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.MalformedDelivery"); + } + + [Fact] + public void Parse_PushDelivery_ParsesReferenceHeadAndRepository() + { + // Arrange + var delivery = SignedDelivery(JsonSerializer.Serialize(new + { + type = "push", + deliveryId = "d-push", + repository = "acme/billing", + reference = "refs/heads/main", + headCommitSha = "abc123", + })); + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsSuccess.Should().BeTrue(); + var push = result.Value.Should().BeOfType().Which; + push.DeliveryId.Should().Be("d-push"); + push.Reference.Should().Be("refs/heads/main"); + push.HeadCommitSha.Should().Be("abc123"); + push.Repository.Should().Be(new GitRepositoryRef("acme", "billing")); + } + + [Fact] + public void Parse_UnknownType_ReturnsUnsupportedEventWithDeliveryId() + { + // Arrange + var delivery = SignedDelivery(JsonSerializer.Serialize(new + { + type = "issues", + deliveryId = "d-unsupported", + repository = "acme/billing", + })); + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsSuccess.Should().BeTrue(); + var unsupported = result.Value.Should().BeOfType().Which; + unsupported.ProviderEventType.Should().Be("issues"); + unsupported.DeliveryId.Should().Be("d-unsupported"); + } + + [Fact] + public void Parse_ConnectionChanged_ParsesInstallationFields() + { + // Arrange + var delivery = SignedDelivery(JsonSerializer.Serialize(new + { + type = "connection_changed", + deliveryId = "d-conn", + @namespace = "acme", + accountType = "User", + installationId = "inst-9", + change = "Suspended", + })); + + // Act + var result = _server.WebhookIngestor.Parse(delivery, InMemoryGitServer.WellKnownWebhookSecret); + + // Assert + result.IsSuccess.Should().BeTrue(); + var changed = result.Value.Should().BeOfType().Which; + changed.DeliveryId.Should().Be("d-conn"); + changed.Namespace.Should().Be("acme"); + changed.AccountType.Should().Be(GitAccountType.User); + changed.InstallationId.Should().Be("inst-9"); + changed.Change.Should().Be(GitConnectionChangeKind.Suspended); + } + + // ---- call log ------------------------------------------------------- + + [Fact] + public async Task Calls_RecordsPerformedOperationsInOrder() + { + // Arrange / Act + await _server.NamespaceProvisioner.CreateNamespaceAsync( + TokenConnection, new CreateGitNamespace { Name = "acme" }); + await _server.Credentials.MintAsync(TokenConnection); + + // Assert + _server.Calls.Should().Contain("CreateNamespace acme"); + _server.Calls.Should().Contain(c => c.StartsWith("Mint")); + } + + // ---- additional fake behaviors (branch coverage) -------------------- + + [Fact] + public async Task Validate_WithTokenCredential_ReportsDefaultIdentity() + { + // Arrange / Act + var result = await _server.Credentials.ValidateAsync(TokenConnection); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.AccountLogin.Should().Be("in-memory-user"); + result.Value.AccountType.Should().Be(GitAccountType.User); + } + + [Fact] + public async Task ListRepositories_ReturnsOnlyTheNamespacesRepositories() + { + // Arrange + _server.SeedRepository(new GitRepositoryRef("acme", "one")); + _server.SeedRepository(new GitRepositoryRef("acme", "two")); + _server.SeedRepository(new GitRepositoryRef("globex", "other")); + + // Act + var result = await _server.Repositories.ListAsync(TokenConnection, "acme"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Select(r => r.Ref.Name).Should().BeEquivalentTo("one", "two"); + } + + [Fact] + public async Task ListBranches_ReturnsTheSeededDefaultBranch() + { + // Arrange + _server.SeedRepository(Repo); + + // Act + var result = await _server.Repositories.ListBranchesAsync(TokenConnection, Repo); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle().Which.Name.Should().Be("main"); + result.Value[0].Protected.Should().BeFalse(); + } + + [Fact] + public async Task ListBranches_OnMissingRepository_FailsRepositoryNotFound() + { + // Arrange / Act + var result = await _server.Repositories.ListBranchesAsync(TokenConnection, Repo); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task Webhooks_OnRepositoryTargetForUnseededRepo_AreStoredAndListed() + { + // Arrange + var target = new GitWebhookTarget.Repository(new GitRepositoryRef("ghost", "repo")); + var request = new EnsureGitWebhook + { + Url = new Uri("https://platform.invalid/webhooks/git"), + Secret = "secret", + Events = ["push"], + }; + + // Act + var ensured = await _server.Webhooks.EnsureAsync(TokenConnection, target, request); + var list = await _server.Webhooks.ListAsync(TokenConnection, target); + + // Assert + ensured.IsSuccess.Should().BeTrue(); + list.Value.Should().ContainSingle().Which.Id.Should().Be(ensured.Value.Id); + } + + [Fact] + public async Task CreateTag_OnSeededRepository_TagsTheDefaultBranchHead() + { + // Arrange + _server.SeedRepository(Repo); + var branches = await _server.Repositories.ListBranchesAsync(TokenConnection, Repo); + var headSha = branches.Value.Single().Sha; + + // Act + var tag = await _server.Repositories.CreateTagAsync(TokenConnection, Repo, "v1.0.0"); + + // Assert + tag.IsSuccess.Should().BeTrue(); + tag.Value.Name.Should().Be("v1.0.0"); + tag.Value.Sha.Should().Be(headSha); + } + + [Fact] + public async Task RepositoryReads_OnMissingRepository_AllFailRepositoryNotFound() + { + // Arrange / Act / Assert + (await _server.Repositories.ListTagsAsync(TokenConnection, Repo)).Error.Code.Should().Be("Git.RepositoryNotFound"); + (await _server.Repositories.CreateReleaseAsync(TokenConnection, Repo, new CreateGitRelease { TagName = "v1" })) + .Error.Code.Should().Be("Git.RepositoryNotFound"); + (await _server.Pipelines.TriggerAsync(TokenConnection, Repo, new TriggerGitPipeline { Pipeline = "b.yml", Reference = "main" })) + .Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task DeleteVariable_RemovesTheVariable_AndIsIdempotentWhenAbsent() + { + // Arrange + _server.SeedRepository(Repo); + var scope = new GitConfigurationScope.Repository(Repo); + await _server.CiConfiguration.SetVariablesAsync( + TokenConnection, scope, new Dictionary { ["LOG_LEVEL"] = "debug" }); + + // Act + var removed = await _server.CiConfiguration.DeleteVariableAsync(TokenConnection, scope, "LOG_LEVEL"); + var absent = await _server.CiConfiguration.DeleteVariableAsync(TokenConnection, scope, "NEVER_SET"); + + // Assert + removed.IsSuccess.Should().BeTrue(); + absent.IsSuccess.Should().BeTrue(); + _server.GetVariables(scope).Should().NotContainKey("LOG_LEVEL"); + } + + [Fact] + public async Task SetSecrets_AtEnvironmentScope_RetainsTheNameUnderThatScope() + { + // Arrange + _server.SeedRepository(Repo); + var scope = new GitConfigurationScope.Environment(Repo, "production"); + + // Act + await _server.CiConfiguration.SetSecretsAsync( + TokenConnection, scope, new Dictionary { ["DEPLOY_KEY"] = "value" }); + + // Assert + _server.GetSecretNames(scope).Should().ContainSingle().Which.Should().Be("DEPLOY_KEY"); + } + + [Fact] + public async Task Environment_EnsureDeleteList_CoverMissingRepositoryAndRoundTrip() + { + // Arrange + var missing = await _server.Environments.EnsureAsync(TokenConnection, Repo, new EnsureGitEnvironment { Name = "production" }); + missing.Error.Code.Should().Be("Git.RepositoryNotFound"); + (await _server.Environments.ListAsync(TokenConnection, Repo)).Error.Code.Should().Be("Git.RepositoryNotFound"); + + _server.SeedRepository(Repo); + await _server.Environments.EnsureAsync(TokenConnection, Repo, new EnsureGitEnvironment { Name = "production" }); + + // Act + var deleted = await _server.Environments.DeleteAsync(TokenConnection, Repo, "production"); + var list = await _server.Environments.ListAsync(TokenConnection, Repo); + + // Assert + deleted.IsSuccess.Should().BeTrue(); + list.Value.Should().BeEmpty(); + } + + [Fact] + public async Task BranchPolicy_ApplyRemoveList_CoverMissingRepositoryAndRoundTrip() + { + // Arrange + var missing = await _server.BranchPolicies.ApplyAsync(TokenConnection, Repo, new GitBranchPolicyRequest { Pattern = "main" }); + missing.Error.Code.Should().Be("Git.RepositoryNotFound"); + (await _server.BranchPolicies.ListAsync(TokenConnection, Repo)).Error.Code.Should().Be("Git.RepositoryNotFound"); + + _server.SeedRepository(Repo); + var policy = await _server.BranchPolicies.ApplyAsync(TokenConnection, Repo, new GitBranchPolicyRequest { Pattern = "main" }); + + // Act + var removed = await _server.BranchPolicies.RemoveAsync(TokenConnection, Repo, policy.Value.Id); + var list = await _server.BranchPolicies.ListAsync(TokenConnection, Repo); + + // Assert + removed.IsSuccess.Should().BeTrue(); + list.Value.Should().BeEmpty(); + } + + [Fact] + public async Task AccessControl_UserAndTeamRepositoryRoles_RoundTrip() + { + // Arrange + _server.SeedRepository(Repo); + var team = await _server.AccessControl.EnsureTeamAsync(TokenConnection, "acme", new EnsureGitTeam { Name = "Platform" }); + + // Act + var teamRole = await _server.AccessControl.SetTeamRepositoryRoleAsync( + TokenConnection, "acme", team.Value.Slug, Repo, GitRepositoryRole.Maintain); + var userRole = await _server.AccessControl.SetUserRepositoryRoleAsync( + TokenConnection, Repo, "octocat", GitRepositoryRole.Admin); + var removed = await _server.AccessControl.RemoveUserFromRepositoryAsync(TokenConnection, Repo, "octocat"); + + // Assert + teamRole.IsSuccess.Should().BeTrue(); + userRole.IsSuccess.Should().BeTrue(); + removed.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Webhooks_OnNamespaceTarget_EnsureListDeleteRoundTrip() + { + // Arrange + var target = new GitWebhookTarget.Namespace("acme"); + var request = new EnsureGitWebhook + { + Url = new Uri("https://platform.invalid/webhooks/git"), + Secret = "secret", + Events = ["push"], + }; + + // Act + var ensured = await _server.Webhooks.EnsureAsync(TokenConnection, target, request); + var list = await _server.Webhooks.ListAsync(TokenConnection, target); + var deleted = await _server.Webhooks.DeleteAsync(TokenConnection, target, ensured.Value.Id); + var afterDelete = await _server.Webhooks.ListAsync(TokenConnection, target); + + // Assert + ensured.IsSuccess.Should().BeTrue(); + list.Value.Should().ContainSingle(); + deleted.IsSuccess.Should().BeTrue(); + afterDelete.Value.Should().BeEmpty(); + } + + private static GitWebhookDelivery SignedDelivery(string body) => new() + { + Body = body, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-InMemory-Signature"] = InMemoryGitServer.WellKnownWebhookSecret, + }, + }; +} diff --git a/tests/Unit/Compendium.Abstractions.Git.Tests/NullGitServerTests.cs b/tests/Unit/Compendium.Abstractions.Git.Tests/NullGitServerTests.cs new file mode 100644 index 0000000..0f7e0b5 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Git.Tests/NullGitServerTests.cs @@ -0,0 +1,239 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Git.AccessControl; +using Compendium.Abstractions.Git.Capabilities; +using Compendium.Abstractions.Git.CiConfiguration; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Environments; +using Compendium.Abstractions.Git.Pipelines; +using Compendium.Abstractions.Git.Protection; +using Compendium.Abstractions.Git.Provisioning; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Stubs; +using Compendium.Abstractions.Git.Webhooks; + +namespace Compendium.Abstractions.Git.Tests; + +/// +/// Unit tests for , the fail-fast stub for hosts +/// without a configured git server: every operation across all eleven ports +/// (including the explicit-interface implementations) must return a +/// Git.NotConfigured failure and never throw, and the capability matrix +/// must be empty. +/// +public sealed class NullGitServerTests +{ + private readonly NullGitServer _server = new(); + + private static readonly GitConnection Connection = new() + { + Provider = NullGitServer.ProviderName, + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + private static void AssertNotConfigured(Result result) + { + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.NotConfigured"); + result.Error.Type.Should().Be(ErrorType.Unavailable); + } + + [Fact] + public void Provider_IsNull() + { + // Arrange / Act / Assert + _server.Provider.Should().Be("null"); + NullGitServer.ProviderName.Should().Be("null"); + } + + [Fact] + public void Capabilities_AreEmpty_AndSupportNothing() + { + // Arrange / Act + var capabilities = _server.Capabilities; + + // Assert + capabilities.Provider.Should().Be("null"); + capabilities.Entries.Should().BeEmpty(); + foreach (var capability in Enum.GetValues()) + { + capabilities.Supports(capability).Should().BeFalse($"{capability} must be unsupported by the null server"); + } + } + + [Fact] + public void FacadeProperties_AllReturnTheSameInstance() + { + // Arrange / Act / Assert + _server.Credentials.Should().BeSameAs(_server); + _server.Repositories.Should().BeSameAs(_server); + _server.Pipelines.Should().BeSameAs(_server); + _server.CiConfiguration.Should().BeSameAs(_server); + _server.Environments.Should().BeSameAs(_server); + _server.BranchPolicies.Should().BeSameAs(_server); + _server.AccessControl.Should().BeSameAs(_server); + _server.Webhooks.Should().BeSameAs(_server); + _server.WebhookIngestor.Should().BeSameAs(_server); + _server.NamespaceProvisioner.Should().BeSameAs(_server); + } + + [Fact] + public async Task Credentials_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var broker = _server.Credentials; + + // Act / Assert + AssertNotConfigured(await broker.MintAsync(Connection)); + AssertNotConfigured(await broker.MintAsync(Connection, new GitAccessTokenScope())); + AssertNotConfigured(await broker.ValidateAsync(Connection)); + AssertNotConfigured(await broker.ResolveAppInstallationAsync("acme")); + AssertNotConfigured(await broker.ListAppInstallationsAsync()); + } + + [Fact] + public async Task Repositories_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var repositories = _server.Repositories; + var template = new CreateRepositoryFromTemplate + { + Template = new GitRepositoryRef("platform", "template"), + Namespace = "acme", + Name = "billing", + }; + + // Act / Assert + AssertNotConfigured(await repositories.CreateFromTemplateAsync(Connection, template)); + AssertNotConfigured(await repositories.GetAsync(Connection, Repo)); + AssertNotConfigured(await repositories.ListAsync(Connection, "acme")); + AssertNotConfigured(await repositories.FileExistsAsync(Connection, Repo, ".bootstrapped")); + AssertNotConfigured(await repositories.ListCommitsAsync(Connection, Repo, reference: null, limit: 10)); + AssertNotConfigured(await repositories.ListBranchesAsync(Connection, Repo)); + AssertNotConfigured(await repositories.CreateTagAsync(Connection, Repo, "v1.0.0")); + AssertNotConfigured(await repositories.ListTagsAsync(Connection, Repo)); + AssertNotConfigured(await repositories.CreateReleaseAsync(Connection, Repo, new CreateGitRelease { TagName = "v1.0.0" })); + } + + [Fact] + public async Task Pipelines_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var pipelines = _server.Pipelines; + var trigger = new TriggerGitPipeline { Pipeline = "bootstrap.yml", Reference = "main" }; + + // Act / Assert + AssertNotConfigured(await pipelines.TriggerAsync(Connection, Repo, trigger)); + AssertNotConfigured(await pipelines.GetRunAsync(Connection, Repo, "run-1")); + AssertNotConfigured(await pipelines.ListRunsAsync(Connection, Repo, new ListGitPipelineRuns())); + } + + [Fact] + public async Task CiConfiguration_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var config = _server.CiConfiguration; + var scope = new GitConfigurationScope.Repository(Repo); + var payload = new Dictionary { ["KEY"] = "value" }; + + // Act / Assert + AssertNotConfigured(await config.SetSecretsAsync(Connection, scope, payload)); + AssertNotConfigured(await config.DeleteSecretAsync(Connection, scope, "KEY")); + AssertNotConfigured(await config.SetVariablesAsync(Connection, scope, payload)); + AssertNotConfigured(await config.DeleteVariableAsync(Connection, scope, "KEY")); + } + + [Fact] + public async Task Environments_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var environments = _server.Environments; + + // Act / Assert + AssertNotConfigured(await environments.EnsureAsync(Connection, Repo, new EnsureGitEnvironment { Name = "production" })); + AssertNotConfigured(await environments.DeleteAsync(Connection, Repo, "production")); + AssertNotConfigured(await environments.ListAsync(Connection, Repo)); + } + + [Fact] + public async Task BranchPolicies_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var policies = _server.BranchPolicies; + + // Act / Assert + AssertNotConfigured(await policies.ApplyAsync(Connection, Repo, new GitBranchPolicyRequest { Pattern = "main" })); + AssertNotConfigured(await policies.RemoveAsync(Connection, Repo, "policy-main")); + AssertNotConfigured(await policies.ListAsync(Connection, Repo)); + } + + [Fact] + public async Task AccessControl_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var accessControl = _server.AccessControl; + + // Act / Assert + AssertNotConfigured(await accessControl.EnsureTeamAsync(Connection, "acme", new EnsureGitTeam { Name = "Platform" })); + AssertNotConfigured(await accessControl.AddTeamMemberAsync(Connection, "acme", "platform", "octocat", GitTeamRole.Member)); + AssertNotConfigured(await accessControl.SetTeamRepositoryRoleAsync(Connection, "acme", "platform", Repo, GitRepositoryRole.Write)); + AssertNotConfigured(await accessControl.SetUserRepositoryRoleAsync(Connection, Repo, "octocat", GitRepositoryRole.Admin)); + AssertNotConfigured(await accessControl.RemoveUserFromRepositoryAsync(Connection, Repo, "octocat")); + } + + [Fact] + public async Task Webhooks_EveryMethod_ReturnsNotConfigured() + { + // Arrange + var webhooks = _server.Webhooks; + var target = new GitWebhookTarget.Repository(Repo); + var request = new EnsureGitWebhook + { + Url = new Uri("https://platform.invalid/webhooks/git"), + Secret = "secret", + Events = ["push"], + }; + + // Act / Assert + AssertNotConfigured(await webhooks.EnsureAsync(Connection, target, request)); + AssertNotConfigured(await webhooks.DeleteAsync(Connection, target, "hook-1")); + AssertNotConfigured(await webhooks.ListAsync(Connection, target)); + } + + [Fact] + public void WebhookIngestor_Parse_ReturnsNotConfigured() + { + // Arrange + var delivery = new GitWebhookDelivery + { + Body = "{}", + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + + // Act + var result = _server.WebhookIngestor.Parse(delivery, "secret"); + + // Assert + AssertNotConfigured(result); + } + + [Fact] + public async Task NamespaceProvisioner_CreateNamespace_ReturnsNotConfigured() + { + // Arrange + var provisioner = _server.NamespaceProvisioner; + + // Act + var result = await provisioner.CreateNamespaceAsync(Connection, new CreateGitNamespace { Name = "NXS-Acme" }); + + // Assert + AssertNotConfigured(result); + } +} From b673865fcdd906edf3d06773df328b863aa9d06d Mon Sep 17 00:00:00 2001 From: sacha Date: Fri, 24 Jul 2026 11:32:15 +0200 Subject: [PATCH 3/4] feat(git): add Compendium.Adapters.GitHub (in-tree, GitHub App auth) GitHub adapter for the git-server abstraction: hybrid Octokit + raw-REST executor (Actions variables, rulesets, environments, env secrets), GitHub App auth (RS256 app JWT <=9min, installation tokens cached per app+installation with 60s refresh skew, scoped mints bypass the cache, stale-JWT 401 retried once), libsodium sealed-box secret encryption, fail-closed HMAC-SHA256 webhook ingestor (constant-time compare) translating push/tag/PR/workflow_run and installation lifecycle events to the neutral GitWebhookEvent union. Capability matrix shipped as CAPABILITIES.md (packed in the nupkg): NamespaceProvisioning=None on github.com (enterprise-owner user token required, unreachable with App credentials), PipelineTrigger=Partial (workflow_dispatch returns no run id). Implementation types are internal; the package surface is AddGitHubGitServer + options. Grep-guard test keeps the source free of platform-specific literals. Also pins ServerUrl semantics in the abstraction: GitConnection.ServerUrl is the API base URL, not the web host root. 170 adapter tests (WireMock + Octokit-against-WireMock), 93.4% line coverage. ADR-0006 migration log: scheduled for extraction to compendium-adapter-github at the first stable tag. Claude-Session: https://claude.ai/code/session_01VFDn7gCH7vdvT4eoNhsMLr --- Compendium.sln | 14 + Directory.Packages.props | 5 + docs/adr/0006-multi-repo-adapter-split.md | 10 + .../Connections/GitConnection.cs | 9 +- .../Auth/AuthorizedConnection.cs | 20 ++ .../Auth/GitHubAppTokenService.cs | 256 +++++++++++++++++ .../Auth/GitHubCredentialBroker.cs | 227 +++++++++++++++ .../CAPABILITIES.md | 36 +++ .../Compendium.Adapters.GitHub.csproj | 36 +++ .../Configuration/GitHubAdapterOptions.cs | 99 +++++++ .../ServiceCollectionExtensions.cs | 85 ++++++ .../GitHubCapabilities.cs | 55 ++++ .../GitHubConnectionExtensions.cs | 26 ++ .../GitHubDefaults.cs | 45 +++ .../GitHubGitServer.cs | 82 ++++++ .../GlobalUsings.cs | 19 ++ .../Http/GitHubClientProvider.cs | 87 ++++++ .../Http/GitHubErrorMapper.cs | 124 ++++++++ .../Http/GitHubRestDtos.cs | 93 ++++++ .../Http/GitHubRestExecutor.cs | 198 +++++++++++++ .../Http/GitRestErrorContext.cs | 36 +++ .../Compendium.Adapters.GitHub/README.md | 56 ++++ .../Security/GitHubSecretSealer.cs | 38 +++ .../Services/GitHubAccessControlService.cs | 168 +++++++++++ .../Services/GitHubBranchPolicyService.cs | 200 +++++++++++++ .../Services/GitHubCiConfigurationService.cs | 235 +++++++++++++++ .../Services/GitHubEnvironmentService.cs | 103 +++++++ .../Services/GitHubNamespaceProvisioner.cs | 26 ++ .../Services/GitHubPipelineService.cs | 135 +++++++++ .../Services/GitHubPipelineStatusMapper.cs | 41 +++ .../Services/GitHubRepositoryService.cs | 272 ++++++++++++++++++ .../Services/GitHubWebhookService.cs | 221 ++++++++++++++ .../Webhooks/GitHubWebhookIngestor.cs | 248 ++++++++++++++++ .../Compendium.Adapters.GitHub.Tests.csproj | 43 +++ .../GitHubAccessControlServiceTests.cs | 109 +++++++ .../GitHubAppTokenServiceTests.cs | 213 ++++++++++++++ .../GitHubBranchPolicyServiceTests.cs | 88 ++++++ .../GitHubCapabilitiesAndProvisionerTests.cs | 78 +++++ .../GitHubCiConfigurationServiceTests.cs | 140 +++++++++ .../GitHubClientProviderTests.cs | 49 ++++ .../GitHubCredentialBrokerTests.cs | 234 +++++++++++++++ .../GitHubEnvironmentServiceTests.cs | 61 ++++ .../GitHubErrorMapperTests.cs | 156 ++++++++++ .../GitHubPipelineServiceTests.cs | 108 +++++++ .../GitHubPipelineStatusMapperTests.cs | 34 +++ .../GitHubRepositoryServiceTests.cs | 227 +++++++++++++++ .../GitHubRestExecutorTests.cs | 202 +++++++++++++ .../GitHubSecretSealerTests.cs | 54 ++++ .../GitHubWebhookIngestorTests.cs | 212 ++++++++++++++ .../GitHubWebhookServiceTests.cs | 121 ++++++++ .../GlobalUsings.cs | 21 ++ .../Infrastructure/GitHubTestHarness.cs | 104 +++++++ .../Infrastructure/Json.cs | 26 ++ .../Infrastructure/StubClientProvider.cs | 32 +++ .../Infrastructure/TestHttpClientFactory.cs | 17 ++ .../NoSassyLiteralsTests.cs | 70 +++++ .../ServiceCollectionExtensionsTests.cs | 76 +++++ .../ServiceErrorPathTests.cs | 225 +++++++++++++++ 58 files changed, 6002 insertions(+), 3 deletions(-) create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Http/GitRestErrorContext.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/README.md create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Security/GitHubSecretSealer.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubAccessControlService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubBranchPolicyService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubCiConfigurationService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubEnvironmentService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubNamespaceProvisioner.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineStatusMapper.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubRepositoryService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Services/GitHubWebhookService.cs create mode 100644 src/Adapters/Compendium.Adapters.GitHub/Webhooks/GitHubWebhookIngestor.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/Compendium.Adapters.GitHub.Tests.csproj create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAccessControlServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAppTokenServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubBranchPolicyServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCapabilitiesAndProvisionerTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCiConfigurationServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubClientProviderTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCredentialBrokerTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubEnvironmentServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubErrorMapperTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineStatusMapperTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRepositoryServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRestExecutorTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubSecretSealerTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookIngestorTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookServiceTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/GlobalUsings.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/GitHubTestHarness.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/Json.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/StubClientProvider.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/TestHttpClientFactory.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/NoSassyLiteralsTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceCollectionExtensionsTests.cs create mode 100644 tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceErrorPathTests.cs diff --git a/Compendium.sln b/Compendium.sln index 17a5767..933c44e 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -187,6 +187,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git.Tests", "tests\Unit\Compendium.Abstractions.Git.Tests\Compendium.Abstractions.Git.Tests.csproj", "{C0C1EAC1-5C33-4451-8CD6-2521A8D13557}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub", "src\Adapters\Compendium.Adapters.GitHub\Compendium.Adapters.GitHub.csproj", "{4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub.Tests", "tests\Unit\Compendium.Adapters.GitHub.Tests\Compendium.Adapters.GitHub.Tests.csproj", "{5FE1601C-93E1-4E92-9AC9-521AA26592CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -484,6 +488,14 @@ Global {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Debug|Any CPU.Build.0 = Debug|Any CPU {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.ActiveCfg = Release|Any CPU {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.Build.0 = Release|Any CPU + {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Release|Any CPU.Build.0 = Release|Any CPU + {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6} @@ -571,5 +583,7 @@ Global {262B8E91-B53C-49BC-833D-739DD4CFC2E1} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} {25E575EB-5677-4BEE-A92A-D8832C27F87A} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} {C0C1EAC1-5C33-4451-8CD6-2521A8D13557} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} + {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB} + {5FE1601C-93E1-4E92-9AC9-521AA26592CB} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props index 9788d79..074e96e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,11 @@ + + + + diff --git a/docs/adr/0006-multi-repo-adapter-split.md b/docs/adr/0006-multi-repo-adapter-split.md index 8fe3947..b040755 100644 --- a/docs/adr/0006-multi-repo-adapter-split.md +++ b/docs/adr/0006-multi-repo-adapter-split.md @@ -133,4 +133,14 @@ Adapters extracted from this monorepo (chronological): All seven heavy adapters now live in their own repositories. The framework's `tests/Integration` no longer requires Docker (per [ADR-0007](0007-integration-test-split-and-inmemory-defaults.md)); per-adapter integration tests live alongside the adapter that owns them. The framework CI's line-coverage gate is now strict at ≥ 90 %. +### In-tree adapters staged for extraction + +New adapters may incubate in-tree while their abstraction stabilizes, then follow the same extraction path once the abstraction ships a stable (non-preview) tag: + +| Date added in-tree | Adapter | External SDK(s) | Scheduled extraction repo | Extraction trigger | +|---|---|---|---|---| +| 2026-07-24 | `Compendium.Adapters.GitHub` | Octokit, Sodium.Core | `compendium-adapter-github` | First stable (non-preview) tag of `Compendium.Abstractions.Git`. | + +`Compendium.Adapters.GitHub` is unit-testable end-to-end (WireMock for the REST paths, NSubstitute for the Octokit paths), so unlike the integration-bound adapters above it does not depress the framework's unit-coverage gate and can incubate in-tree without a coverage exemption until the Git abstraction stabilizes. + The convenience meta-package `Compendium.Extensions.ExternalAdapters` (which previously re-exposed Zitadel + Listmonk + LemonSqueezy DI helpers) was removed as part of this transition — consumers wire each adapter directly through its own DI extension method (`AddStripeBilling`, `AddZitadelIdentity`, etc.). diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs index d95bb11..cfae6c5 100644 --- a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs +++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs @@ -21,9 +21,12 @@ public sealed record GitConnection public required string Provider { get; init; } /// - /// Gets the base API URL for self-hosted instances (GitLab on-prem, Gitea, - /// GitHub Enterprise Server). targets the provider's - /// cloud service. + /// Gets the API base URL for self-hosted instances — the full prefix API + /// requests are issued against, not the web host root (GitHub Enterprise + /// Server: https://ghes.example.com/api/v3; GitLab on-prem: + /// https://gitlab.example.com/api/v4). + /// targets the provider's cloud service. Adapters must follow this + /// convention regardless of their SDK's own base-URL handling. /// public Uri? ServerUrl { get; init; } diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs new file mode 100644 index 0000000..2492e5e --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Auth; + +/// +/// A minted bearer token paired with the resolved REST API base URL for a +/// connection — the authorization preamble the REST-backed services share. +/// +/// The bearer token to send. Redacted in . +/// The trailing-slashed REST API base URL for the connection. +internal sealed record AuthorizedConnection(string Token, Uri ApiBase) +{ + /// + public override string ToString() => $"AuthorizedConnection(***, {ApiBase})"; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs new file mode 100644 index 0000000..091cb31 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs @@ -0,0 +1,256 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Collections.Concurrent; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Http; +using Microsoft.Extensions.Logging; + +namespace Compendium.Adapters.GitHub.Auth; + +/// +/// Mints GitHub App credentials: signs App-level JWTs (RS256) with a registration's +/// private key and exchanges them for installation access tokens via +/// POST /app/installations/{id}/access_tokens. Unscoped tokens are cached +/// per (app, installation) and refreshed shortly before expiry; a stale-JWT 401 on +/// mint is retried once with a freshly signed JWT. Independent of Octokit so the +/// minted token works against REST, GraphQL, and git-over-HTTPS alike. +/// +internal sealed class GitHubAppTokenService +{ + private static readonly TimeSpan JwtLifetime = TimeSpan.FromMinutes(9); + private static readonly TimeSpan JwtBackdate = TimeSpan.FromSeconds(30); + private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + public GitHubAppTokenService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Signs an App-level JWT (RS256) for with + /// iss = the app id and a lifetime under GitHub's 10-minute ceiling. + /// + public Result CreateAppJwt(GitHubAppRegistration app) + { + ArgumentNullException.ThrowIfNull(app); + + if (string.IsNullOrWhiteSpace(app.AppId)) + { + return Result.Failure(Error.Failure( + "GitHubApp.AppIdMissing", "The GitHub App registration has no AppId configured.")); + } + + if (string.IsNullOrWhiteSpace(app.PrivateKeyPem)) + { + return Result.Failure(Error.Failure( + "GitHubApp.PrivateKeyMissing", "The GitHub App registration has no private key configured.")); + } + + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(app.PrivateKeyPem); + + var now = DateTimeOffset.UtcNow; + var payload = new + { + iat = now.Subtract(JwtBackdate).ToUnixTimeSeconds(), + exp = now.Add(JwtLifetime).ToUnixTimeSeconds(), + iss = app.AppId, + }; + + var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "RS256", typ = "JWT" })); + var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload)); + var signingInput = $"{encodedHeader}.{encodedPayload}"; + + var signature = rsa.SignData( + Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return Result.Success($"{signingInput}.{Base64UrlEncode(signature)}"); + } + catch (Exception ex) when (ex is CryptographicException or ArgumentException or FormatException) + { + _logger.LogError(ex, "Failed to sign a GitHub App JWT"); + return Result.Failure(Error.Failure( + "GitHubApp.JwtSigningFailed", $"Failed to sign the GitHub App JWT: {ex.Message}")); + } + } + + /// + /// Returns an installation access token for , + /// serving a cached unscoped token when it is still fresh. Scoped mints + /// ( non-null) always bypass the cache. + /// + public async Task> GetInstallationTokenAsync( + GitHubAppRegistration app, + string? appKey, + Uri apiBaseUrl, + string installationId, + GitAccessTokenScope? scope, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(apiBaseUrl); + + if (string.IsNullOrWhiteSpace(installationId)) + { + return Result.Failure(Error.Validation( + "GitHubApp.InstallationIdRequired", "A GitHub App installation id is required.")); + } + + var cacheKey = $"{appKey ?? "default"}:{installationId}"; + if (scope is null + && _cache.TryGetValue(cacheKey, out var cached) + && cached.ExpiresAt > DateTimeOffset.UtcNow.Add(RefreshSkew)) + { + return Result.Success(cached); + } + + var minted = await MintAsync(app, apiBaseUrl, installationId, scope, retryOnUnauthorized: true, cancellationToken) + .ConfigureAwait(false); + if (minted.IsFailure) + { + return minted; + } + + if (scope is null) + { + _cache[cacheKey] = minted.Value; + } + + return minted; + } + + /// Drops any cached token for an (app, installation) pair. + public void Invalidate(string? appKey, string installationId) => + _cache.TryRemove($"{appKey ?? "default"}:{installationId}", out _); + + private async Task> MintAsync( + GitHubAppRegistration app, + Uri apiBaseUrl, + string installationId, + GitAccessTokenScope? scope, + bool retryOnUnauthorized, + CancellationToken cancellationToken) + { + var jwtResult = CreateAppJwt(app); + if (jwtResult.IsFailure) + { + return Result.Failure(jwtResult.Error); + } + + try + { + using var request = new HttpRequestMessage( + HttpMethod.Post, + new Uri(apiBaseUrl, $"app/installations/{Uri.EscapeDataString(installationId)}/access_tokens")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtResult.Value); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", GitHubDefaults.ApiVersion); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue(GitHubDefaults.ProductName, "1.0")); + + var body = BuildScopeBody(scope); + if (body is not null) + { + request.Content = JsonContent.Create(body); + } + + var http = _httpClientFactory.CreateClient(GitHubDefaults.HttpClientName); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // A stale App JWT (clock skew) reads as 401; re-sign once and retry. + if (response.StatusCode == HttpStatusCode.Unauthorized && retryOnUnauthorized) + { + _logger.LogWarning( + "GitHub rejected the App JWT minting installation {InstallationId}; re-signing and retrying once.", + installationId); + return await MintAsync(app, apiBaseUrl, installationId, scope, retryOnUnauthorized: false, cancellationToken) + .ConfigureAwait(false); + } + + if (!response.IsSuccessStatusCode) + { + return Result.Failure(GitHubErrorMapper.FromStatus( + (int)response.StatusCode, + GitRestErrorContext.None, + $"installation token request returned {(int)response.StatusCode}")); + } + + var payload = await response.Content + .ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + + if (payload is null || string.IsNullOrEmpty(payload.Token)) + { + return Result.Failure(Error.Failure( + "GitHubApp.InstallationTokenMalformed", "GitHub returned an empty installation-token payload.")); + } + + return Result.Success(new GitAccessToken + { + Token = payload.Token, + ExpiresAt = payload.ExpiresAt, + HttpBasicUsername = GitHubDefaults.HttpBasicUsername, + }); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP failure minting installation token for {InstallationId}", installationId); + return Result.Failure(Error.Failure( + "GitHubApp.InstallationTokenNetwork", $"HTTP error minting the installation token: {ex.Message}")); + } + } + + private static object? BuildScopeBody(GitAccessTokenScope? scope) + { + if (scope is null) + { + return null; + } + + var body = new Dictionary(StringComparer.Ordinal); + + // The create-installation-token endpoint accepts repository NAMES (which the + // neutral scope carries) as well as numeric repository_ids; names avoid a lookup. + if (scope.Repositories is { Count: > 0 } repositories) + { + body["repositories"] = repositories.Select(r => r.Name).ToArray(); + } + + if (scope.Permissions is { Count: > 0 } permissions) + { + body["permissions"] = permissions.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal); + } + + return body.Count > 0 ? body : null; + } + + private static string Base64UrlEncode(byte[] input) => + Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed class InstallationTokenResponse + { + [JsonPropertyName("token")] + public string Token { get; init; } = string.Empty; + + [JsonPropertyName("expires_at")] + public DateTimeOffset ExpiresAt { get; init; } + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs new file mode 100644 index 0000000..a2089cd --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs @@ -0,0 +1,227 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Http; +using Microsoft.Extensions.Options; + +namespace Compendium.Adapters.GitHub.Auth; + +/// +/// The GitHub auth seam. Mints short-lived installation tokens from the platform +/// App's private key for connections, +/// and passes through caller-supplied service-account / PAT / OAuth tokens. +/// Validates credentials and discovers App installations across accounts. +/// +internal sealed class GitHubCredentialBroker : IGitCredentialBroker +{ + private static readonly TimeSpan DurableTokenLifetime = TimeSpan.FromDays(3650); + private static readonly TimeSpan OAuthTokenLifetime = TimeSpan.FromHours(8); + + private readonly GitHubAdapterOptions _options; + private readonly GitHubAppTokenService _tokenService; + private readonly GitHubRestExecutor _rest; + + public GitHubCredentialBroker( + IOptions options, + GitHubAppTokenService tokenService, + GitHubRestExecutor rest) + { + ArgumentNullException.ThrowIfNull(options); + _options = options.Value; + _tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService)); + _rest = rest ?? throw new ArgumentNullException(nameof(rest)); + } + + /// + /// Mints an unscoped token for a connection and pairs it with the connection's + /// resolved API base URL — the common preamble for the REST-backed services. + /// + public async Task> AuthorizeAsync( + GitConnection connection, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + var mint = await MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false); + return mint.IsFailure + ? Result.Failure(mint.Error) + : Result.Success(new AuthorizedConnection(mint.Value.Token, connection.ApiBase(_options))); + } + + /// + public Task> MintAsync( + GitConnection connection, GitAccessTokenScope? scope = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + + switch (connection.Credential) + { + case GitCredential.AppInstallation app: + if (_options.ResolveApp(app.AppKey) is not { } registration) + { + return Task.FromResult(Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider))); + } + + return _tokenService.GetInstallationTokenAsync( + registration, app.AppKey, connection.ApiBase(_options), app.InstallationId, scope, cancellationToken); + + case GitCredential.ServiceAccountToken sat: + return Task.FromResult(Result.Success(PassThrough(sat.Token, DurableTokenLifetime))); + + case GitCredential.PersonalAccessToken pat: + return Task.FromResult(Result.Success(PassThrough(pat.Token, DurableTokenLifetime))); + + case GitCredential.OAuthAccessToken oauth: + return Task.FromResult(Result.Success(PassThrough(oauth.AccessToken, OAuthTokenLifetime))); + + default: + return Task.FromResult(Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider))); + } + } + + /// + public async Task> ValidateAsync( + GitConnection connection, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + + if (connection.Credential is GitCredential.AppInstallation app) + { + if (_options.ResolveApp(app.AppKey) is not { } registration) + { + return Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider)); + } + + var jwt = _tokenService.CreateAppJwt(registration); + if (jwt.IsFailure) + { + return Result.Failure(jwt.Error); + } + + var installation = await _rest.GetAsync( + connection.ApiBase(_options), jwt.Value, + $"app/installations/{Uri.EscapeDataString(app.InstallationId)}", + GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + + if (installation.IsFailure) + { + return Result.Failure(installation.Error); + } + + var account = installation.Value.Account; + return Result.Success(new GitConnectionIdentity( + account?.Login ?? string.Empty, + account?.ToAccountType() ?? GitAccountType.Organization, + account?.Name)); + } + + var mint = await MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false); + if (mint.IsFailure) + { + return Result.Failure(mint.Error); + } + + var user = await _rest.GetAsync( + connection.ApiBase(_options), mint.Value.Token, "user", GitRestErrorContext.None, cancellationToken) + .ConfigureAwait(false); + + return user.IsFailure + ? Result.Failure(user.Error) + : Result.Success(new GitConnectionIdentity(user.Value.Login, user.Value.ToAccountType(), user.Value.Name)); + } + + /// + public async Task> ResolveAppInstallationAsync( + string @namespace, string? appKey = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(@namespace); + + if (_options.ResolveApp(appKey) is not { } registration) + { + return Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider)); + } + + var jwt = _tokenService.CreateAppJwt(registration); + if (jwt.IsFailure) + { + return Result.Failure(jwt.Error); + } + + var apiBase = GitHubDefaults.EnsureTrailingSlash(_options.ApiBaseUrl); + var escaped = Uri.EscapeDataString(@namespace); + + var org = await _rest.GetAsync( + apiBase, jwt.Value, $"orgs/{escaped}/installation", GitRestErrorContext.None, cancellationToken) + .ConfigureAwait(false); + if (org.IsSuccess) + { + return Result.Success(org.Value.ToInstallationInfo()); + } + + var user = await _rest.GetAsync( + apiBase, jwt.Value, $"users/{escaped}/installation", GitRestErrorContext.None, cancellationToken) + .ConfigureAwait(false); + if (user.IsSuccess) + { + return Result.Success(user.Value.ToInstallationInfo()); + } + + return Result.Failure(GitErrors.AppNotInstalled(@namespace, BuildInstallUrl(registration))); + } + + /// + public async Task>> ListAppInstallationsAsync( + string? appKey = null, CancellationToken cancellationToken = default) + { + if (_options.ResolveApp(appKey) is not { } registration) + { + return Result.Failure>(GitErrors.NotConfigured(GitHubDefaults.Provider)); + } + + var jwt = _tokenService.CreateAppJwt(registration); + if (jwt.IsFailure) + { + return Result.Failure>(jwt.Error); + } + + var apiBase = GitHubDefaults.EnsureTrailingSlash(_options.ApiBaseUrl); + const int perPage = 100; + var installations = new List(); + + for (var page = 1; ; page++) + { + var pageResult = await _rest.GetAsync>( + apiBase, jwt.Value, $"app/installations?per_page={perPage}&page={page}", + GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + + if (pageResult.IsFailure) + { + return Result.Failure>(pageResult.Error); + } + + installations.AddRange(pageResult.Value.Select(i => i.ToInstallationInfo())); + + if (pageResult.Value.Count < perPage) + { + break; + } + } + + return Result.Success>(installations); + } + + private static GitAccessToken PassThrough(string token, TimeSpan lifetime) => new() + { + Token = token, + ExpiresAt = DateTimeOffset.UtcNow.Add(lifetime), + HttpBasicUsername = GitHubDefaults.HttpBasicUsername, + }; + + private static string? BuildInstallUrl(GitHubAppRegistration registration) => + string.IsNullOrWhiteSpace(registration.AppSlug) + ? null + : $"https://github.com/apps/{registration.AppSlug}/installations/new"; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md b/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md new file mode 100644 index 0000000..0c474e9 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md @@ -0,0 +1,36 @@ +# Compendium.Adapters.GitHub — capability matrix + +Provider discriminator: **`github`**. Targets github.com and GitHub Enterprise +Server (set `GitConnection.ServerUrl` to the GHES API base). + +Levels: **Full** — supported without caveats · **Partial** — supported with the +noted limitation · **None** — not reachable by this adapter (with the reason). + +| Capability | Level | GitHub mechanism | Limitations | +|---|---|---|---| +| `RepositoryFromTemplate` | Full | `POST /repos/{template_owner}/{template_repo}/generate` | — | +| `RepositoryManagement` | Full | `GET /repos/*`, contents, commits, branches | — | +| `TagsAndReleases` | Full | `POST /repos/{o}/{r}/git/refs` (tags), `POST .../releases` | Tag creation makes a lightweight git ref; a release is a separate `CreateReleaseAsync` call. | +| `CiSecrets` | Full | Actions repo secrets, libsodium sealed box | Write-only (values never read back). | +| `CiVariables` | Full | Actions repo variables | — | +| `NamespaceSecrets` | Full | Actions org secrets (`visibility: all`), sealed box | Org variables/secrets default to `all` visibility. | +| `PipelineTrigger` | **Partial** | `POST /repos/{o}/{r}/actions/workflows/{wf}/dispatches` | `workflow_dispatch` returns **204 with no run id** — `GitPipelineRunHandle.RunId` is `null`; correlate the created run via `ListRunsAsync`. | +| `PipelineStatus` | Full | `GET .../actions/runs/{id}`, `.../runs` | Status/conclusion mapped onto `GitPipelineStatus` (unmapped states → `Unknown`). | +| `DeploymentEnvironments` | Full | `PUT/GET/DELETE /repos/{o}/{r}/environments/{name}` | `PUT` is create-or-update (idempotent). | +| `EnvironmentSecrets` | Full | Environment secrets keyed by numeric repository id, sealed box | The repository id is resolved with an extra `GET /repos/{o}/{r}`. | +| `BranchPolicies` | Full | Repository **rulesets** (`/repos/{o}/{r}/rulesets`) | Neutral policy → one ruleset named `compendium:{pattern}`; not classic branch protection. Admin bypass added when `EnforceForAdmins` is false. | +| `TeamsAndPermissions` | Full | Teams, team membership, `AddOrUpdateTeamRepositoryPermissions`, collaborators | Organization namespaces only; team ops on a user account fail as a mapped provider error. Roles map to `pull/triage/push/maintain/admin`. | +| `WebhookManagement` | Full | Repository and organization hooks | Matched by delivery URL for idempotent ensure. | +| `WebhookIngestion` | Full | `X-Hub-Signature-256` HMAC-SHA256 over the raw body | Fail-closed: missing/invalid signature → `Git.WebhookSignatureInvalid`. Consumed events: `push` (incl. tag pushes), `pull_request`, `workflow_run` (completed), `installation`, `installation_repositories`; everything else → `Unsupported`. | +| `NamespaceProvisioning` | **None** | — | github.com organization creation requires an **enterprise-owner user token**; unreachable with App credentials. `CreateNamespaceAsync` always returns `Git.CapabilityNotSupported`. | +| `AppInstallationAuth` | Full | App JWT (RS256) → installation access token | Tokens cached per (app, installation), refreshed 60 s before expiry; a stale-JWT 401 on mint is retried once. | +| `ServiceAccountAuth` | Full | Bearer pass-through of a durable token | Reported with a far-future expiry. | +| `OAuthUserAuth` | Full | Bearer pass-through of an OAuth user token | Reported with an 8-hour expiry. | +| `ScopedTokenMinting` | Full | `repositories` + `permissions` on the create-installation-token request | Scoped tokens bypass the token cache and are minted fresh each call. | + +## Token scoping note + +The neutral `GitAccessTokenScope` carries repository **references** (namespace/name). +GitHub's create-installation-access-token endpoint accepts either numeric +`repository_ids` or a `repositories` array of names; the adapter sends the names, +avoiding an id lookup. At most 500 repositories may be listed. diff --git a/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj b/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj new file mode 100644 index 0000000..0e3fb14 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj @@ -0,0 +1,36 @@ + + + + Compendium.Adapters.GitHub + Compendium.Adapters.GitHub + Compendium.Adapters.GitHub + GitHub adapter for Compendium Framework: a concrete IGitServer implementing the Compendium.Abstractions.Git ports against github.com and GitHub Enterprise Server. Provides GitHub App installation-token auth (RS256 App JWT plus per-installation token minting and caching), repository/tag/release management, CI secrets (libsodium sealed-box encrypted) and variables, deployment environments, repository-ruleset branch policies, teams and collaborators, outbound webhook subscriptions, and fail-closed HMAC-SHA256 inbound webhook ingestion. + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs b/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs new file mode 100644 index 0000000..19777a9 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs @@ -0,0 +1,99 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Configuration; + +/// +/// Options for the GitHub adapter. Holds the platform's GitHub App registration(s) +/// used to mint installation tokens and to build install URLs, plus the REST API +/// base URL for github.com (overridden per connection for GitHub Enterprise Server +/// via ). +/// +public sealed class GitHubAdapterOptions +{ + /// + /// Gets or sets the default GitHub App registration, selected when a + /// carries a + /// AppKey. Its private key and webhook secret are sensitive — bind them + /// from a secret store, never from source or plaintext configuration files. + /// + public GitHubAppRegistration DefaultApp { get; set; } = new(); + + /// + /// Gets the additional named App registrations, selected by + /// . Reserved for the + /// per-instance manifest-creation flow; empty in v1. + /// + public Dictionary Apps { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets the github.com REST API base URL. Defaults to + /// https://api.github.com; a connection's + /// overrides it for GHES. + /// + public Uri ApiBaseUrl { get; set; } = GitHubDefaults.DefaultApiBase; + + /// + /// Resolves a registration by key: the when + /// is , otherwise the entry in + /// . Returns when a non-null key has + /// no registration. + /// + internal GitHubAppRegistration? ResolveApp(string? appKey) + { + if (appKey is null) + { + return DefaultApp; + } + + return Apps.TryGetValue(appKey, out var app) ? app : null; + } +} + +/// +/// A single GitHub App registration: the identity and private key used to mint +/// App JWTs and installation tokens, the slug used to build the install URL, and +/// the webhook signing secret. +/// +public sealed class GitHubAppRegistration +{ + /// + /// Gets or sets the numeric GitHub App id, used as the App JWT iss claim. + /// + public string AppId { get; set; } = string.Empty; + + /// + /// Gets or sets the App's URL slug (the segment after + /// https://github.com/apps/), used to build the install URL surfaced by + /// Git.AppNotInstalled. + /// + public string AppSlug { get; set; } = string.Empty; + + /// + /// Gets or sets the OAuth client id the App advertises, used for the optional + /// user-authorization leg of the install flow. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the OAuth client secret paired with . + /// Sensitive — bind from a secret store. + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Gets or sets the PEM-encoded RSA private key used to sign App JWTs. + /// Sensitive — bind from a secret store and keep out of logs. + /// + public string PrivateKeyPem { get; set; } = string.Empty; + + /// + /// Gets or sets the shared secret used to HMAC-verify inbound webhook + /// deliveries. Rotated independently of . + /// + public string WebhookSecret { get; set; } = string.Empty; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs b/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4ef8a8b --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Auth; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Http; +using Compendium.Adapters.GitHub.Security; +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Webhooks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Compendium.Adapters.GitHub.DependencyInjection; + +/// +/// DI extensions for registering the GitHub adapter. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the GitHub git-server adapter and its concern-scoped ports. The + /// facade is contributed to IEnumerable<IGitServer> for + /// provider-dispatch consumers, and each port is also resolvable directly for + /// single-concern consumers. + /// + /// The service collection. + /// An optional callback to configure adapter options. + /// The service collection, for chaining. + public static IServiceCollection AddGitHubGitServer( + this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configure is not null) + { + services.Configure(configure); + } + else + { + services.AddOptions(); + } + + // A named HttpClient backs both installation-token minting and the raw REST executor. + services.AddHttpClient(GitHubDefaults.HttpClientName); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + // The facade participates in provider dispatch. + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + + // Each concern-scoped port is resolvable on its own for single-concern consumers. + services.TryAddSingleton(sp => sp.GetRequiredService().Credentials); + services.TryAddSingleton(sp => sp.GetRequiredService().Repositories); + services.TryAddSingleton(sp => sp.GetRequiredService().Pipelines); + services.TryAddSingleton(sp => sp.GetRequiredService().CiConfiguration); + services.TryAddSingleton(sp => sp.GetRequiredService().Environments); + services.TryAddSingleton(sp => sp.GetRequiredService().BranchPolicies); + services.TryAddSingleton(sp => sp.GetRequiredService().AccessControl); + services.TryAddSingleton(sp => sp.GetRequiredService().Webhooks); + services.TryAddSingleton(sp => sp.GetRequiredService().WebhookIngestor); + services.TryAddSingleton(sp => sp.GetRequiredService().NamespaceProvisioner); + + return services; + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs new file mode 100644 index 0000000..63c8e2e --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub; + +/// +/// The declared capability matrix for the GitHub adapter on github.com / GHES. +/// The full support/limitation table lives in CAPABILITIES.md; this is its +/// executable counterpart, shared by the facade and the sub-services that guard +/// optional capabilities via . +/// +internal static class GitHubCapabilities +{ + /// + /// Gets the singleton capability matrix. Every capability the adapter can + /// reach on github.com is , except + /// (Partial: workflow_dispatch + /// returns no run id) and + /// (None: org creation needs an enterprise-owner user token). + /// + public static GitServerCapabilities Matrix { get; } = new() + { + Provider = GitHubDefaults.Provider, + Entries = new Dictionary + { + [GitCapability.RepositoryFromTemplate] = new(GitCapabilityLevel.Full), + [GitCapability.RepositoryManagement] = new(GitCapabilityLevel.Full), + [GitCapability.TagsAndReleases] = new(GitCapabilityLevel.Full), + [GitCapability.CiSecrets] = new(GitCapabilityLevel.Full), + [GitCapability.CiVariables] = new(GitCapabilityLevel.Full), + [GitCapability.NamespaceSecrets] = new(GitCapabilityLevel.Full), + [GitCapability.PipelineTrigger] = new( + GitCapabilityLevel.Partial, + "workflow_dispatch returns no run id; correlate the created run via ListRuns."), + [GitCapability.PipelineStatus] = new(GitCapabilityLevel.Full), + [GitCapability.DeploymentEnvironments] = new(GitCapabilityLevel.Full), + [GitCapability.EnvironmentSecrets] = new(GitCapabilityLevel.Full), + [GitCapability.BranchPolicies] = new(GitCapabilityLevel.Full), + [GitCapability.TeamsAndPermissions] = new(GitCapabilityLevel.Full), + [GitCapability.WebhookManagement] = new(GitCapabilityLevel.Full), + [GitCapability.WebhookIngestion] = new(GitCapabilityLevel.Full), + [GitCapability.NamespaceProvisioning] = new( + GitCapabilityLevel.None, + "github.com organization creation requires an enterprise-owner user token; unreachable with App credentials."), + [GitCapability.AppInstallationAuth] = new(GitCapabilityLevel.Full), + [GitCapability.ServiceAccountAuth] = new(GitCapabilityLevel.Full), + [GitCapability.OAuthUserAuth] = new(GitCapabilityLevel.Full), + [GitCapability.ScopedTokenMinting] = new(GitCapabilityLevel.Full), + }, + }; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs new file mode 100644 index 0000000..95fa8a8 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Configuration; + +namespace Compendium.Adapters.GitHub; + +/// +/// Helpers for resolving the REST API base URL a connection should target: +/// a connection's (GHES) when present, +/// otherwise the adapter's configured github.com base, always trailing-slashed. +/// +internal static class GitHubConnectionExtensions +{ + /// Resolves the normalized API base URL for a connection. + public static Uri ApiBase(this GitConnection connection, GitHubAdapterOptions options) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(options); + return GitHubDefaults.EnsureTrailingSlash(connection.ServerUrl ?? options.ApiBaseUrl); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs new file mode 100644 index 0000000..7d2c0a6 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub; + +/// +/// Shared constants for the GitHub adapter: the provider discriminator, the +/// product/user-agent header, the named +/// used for raw REST calls, and the GitHub REST API version pin. +/// +internal static class GitHubDefaults +{ + /// The provider discriminator (matches ). + public const string Provider = "github"; + + /// The Octokit / user-agent product name. Must contain no spaces. + public const string ProductName = "compendium-git"; + + /// The named HttpClient used for raw REST calls the Octokit client lags on. + public const string HttpClientName = "compendium-github"; + + /// The REST API version pin sent as X-GitHub-Api-Version. + public const string ApiVersion = "2022-11-28"; + + /// The HTTP-basic username paired with a minted token for git-over-HTTPS. + public const string HttpBasicUsername = "x-access-token"; + + /// The github.com REST API base URL used when no override is configured. + public static readonly Uri DefaultApiBase = new("https://api.github.com"); + + /// + /// Returns with a trailing slash so relative paths combine + /// against it without truncating a GHES /api/v3 prefix. + /// + public static Uri EnsureTrailingSlash(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + return uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/"); + } +} + diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs new file mode 100644 index 0000000..37a49e6 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs @@ -0,0 +1,82 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Auth; + +namespace Compendium.Adapters.GitHub; + +/// +/// The GitHub facade. Declares Provider = "github" +/// and the capability matrix, and exposes the concern-scoped ports. Stateless and +/// registered as a singleton — all per-tenant state travels in the +/// passed to each port method. +/// +internal sealed class GitHubGitServer : IGitServer +{ + /// The provider discriminator reported by the adapter. + public const string ProviderName = GitHubDefaults.Provider; + + public GitHubGitServer( + GitHubCredentialBroker credentials, + Services.GitHubRepositoryService repositories, + Services.GitHubPipelineService pipelines, + Services.GitHubCiConfigurationService ciConfiguration, + Services.GitHubEnvironmentService environments, + Services.GitHubBranchPolicyService branchPolicies, + Services.GitHubAccessControlService accessControl, + Services.GitHubWebhookService webhooks, + Webhooks.GitHubWebhookIngestor webhookIngestor, + Services.GitHubNamespaceProvisioner namespaceProvisioner) + { + Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); + Repositories = repositories ?? throw new ArgumentNullException(nameof(repositories)); + Pipelines = pipelines ?? throw new ArgumentNullException(nameof(pipelines)); + CiConfiguration = ciConfiguration ?? throw new ArgumentNullException(nameof(ciConfiguration)); + Environments = environments ?? throw new ArgumentNullException(nameof(environments)); + BranchPolicies = branchPolicies ?? throw new ArgumentNullException(nameof(branchPolicies)); + AccessControl = accessControl ?? throw new ArgumentNullException(nameof(accessControl)); + Webhooks = webhooks ?? throw new ArgumentNullException(nameof(webhooks)); + WebhookIngestor = webhookIngestor ?? throw new ArgumentNullException(nameof(webhookIngestor)); + NamespaceProvisioner = namespaceProvisioner ?? throw new ArgumentNullException(nameof(namespaceProvisioner)); + } + + /// + public string Provider => ProviderName; + + /// + public GitServerCapabilities Capabilities => GitHubCapabilities.Matrix; + + /// + public IGitCredentialBroker Credentials { get; } + + /// + public IGitRepositoryService Repositories { get; } + + /// + public IGitPipelineService Pipelines { get; } + + /// + public IGitCiConfigurationService CiConfiguration { get; } + + /// + public IGitEnvironmentService Environments { get; } + + /// + public IGitBranchPolicyService BranchPolicies { get; } + + /// + public IGitAccessControlService AccessControl { get; } + + /// + public IGitWebhookService Webhooks { get; } + + /// + public IGitWebhookIngestor WebhookIngestor { get; } + + /// + public IGitNamespaceProvisioner NamespaceProvisioner { get; } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs b/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs new file mode 100644 index 0000000..575e320 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs @@ -0,0 +1,19 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Abstractions.Git; +global using Compendium.Abstractions.Git.AccessControl; +global using Compendium.Abstractions.Git.Capabilities; +global using Compendium.Abstractions.Git.CiConfiguration; +global using Compendium.Abstractions.Git.Connections; +global using Compendium.Abstractions.Git.Environments; +global using Compendium.Abstractions.Git.Pipelines; +global using Compendium.Abstractions.Git.Protection; +global using Compendium.Abstractions.Git.Provisioning; +global using Compendium.Abstractions.Git.Repositories; +global using Compendium.Abstractions.Git.Webhooks; +global using Compendium.Core.Results; diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs new file mode 100644 index 0000000..4a4a413 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs @@ -0,0 +1,87 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using Compendium.Adapters.GitHub.Auth; +using Octokit; + +namespace Compendium.Adapters.GitHub.Http; + +/// +/// Produces authenticated Octokit clients for a connection. +/// +internal interface IGitHubClientProvider +{ + /// Mints a token for the connection and returns an Octokit client carrying it. + Task> GetClientAsync(GitConnection connection, CancellationToken cancellationToken); +} + +/// +/// Produces authenticated Octokit clients for a connection. A +/// is cached per credential identity and target host +/// (so repeated calls do not exhaust sockets), and its credentials are refreshed +/// from a freshly minted token on every retrieval — installation tokens rotate +/// roughly hourly, and the mint is cheap because the broker caches them. +/// +internal sealed class GitHubClientProvider : IGitHubClientProvider +{ + private readonly GitHubCredentialBroker _broker; + private readonly ConcurrentDictionary _clients = new(StringComparer.Ordinal); + + public GitHubClientProvider(GitHubCredentialBroker broker) + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + } + + /// + public async Task> GetClientAsync(GitConnection connection, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(connection); + + var token = await _broker.MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false); + if (token.IsFailure) + { + return Result.Failure(token.Error); + } + + var client = _clients.GetOrAdd(CacheKey(connection), _ => Build(connection)); + client.Credentials = new Credentials(token.Value.Token); + return Result.Success(client); + } + + private static GitHubClient Build(GitConnection connection) + { + var product = new ProductHeaderValue(GitHubDefaults.ProductName); + return connection.ServerUrl is null + ? new GitHubClient(product) + : new GitHubClient(product, GitHubDefaults.EnsureTrailingSlash(connection.ServerUrl)); + } + + private static string CacheKey(GitConnection connection) + { + var host = connection.ServerUrl?.AbsoluteUri ?? "github.com"; + var identity = connection.Credential switch + { + GitCredential.AppInstallation app => $"app:{app.AppKey ?? "default"}:{app.InstallationId}", + GitCredential.ServiceAccountToken sat => $"tok:{Fingerprint(sat.Token)}", + GitCredential.PersonalAccessToken pat => $"tok:{Fingerprint(pat.Token)}", + GitCredential.OAuthAccessToken oauth => $"tok:{Fingerprint(oauth.AccessToken)}", + _ => "unknown", + }; + + return $"{identity}@{host}"; + } + + // A non-reversible fingerprint so the cache key never carries raw token material. + private static string Fingerprint(string token) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hash.AsSpan(0, 6)); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs new file mode 100644 index 0000000..6ed2566 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs @@ -0,0 +1,124 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Net; +using Octokit; + +namespace Compendium.Adapters.GitHub.Http; + +/// +/// Maps GitHub failures — both raw HTTP status codes (REST executor) and Octokit +/// exceptions — onto the neutral so every provider fails +/// uniformly. Never leaks token material: only status codes and provider messages +/// flow through. +/// +internal static class GitHubErrorMapper +{ + /// The provider discriminator carried in mapped errors. + public const string Provider = GitHubDefaults.Provider; + + /// + /// Maps an HTTP status code (plus an optional rate-limit hint) onto a neutral error. + /// + public static Error FromStatus( + int statusCode, + GitRestErrorContext context, + string detail, + TimeSpan? retryAfter = null, + bool rateLimited = false) + { + switch (statusCode) + { + case 401: + return GitErrors.AuthenticationFailed(Provider, detail); + + case 403 when rateLimited || retryAfter.HasValue: + case 429: + return GitErrors.Throttled(retryAfter); + + case 404: + if (context.RepositoryFullName is { } repo) + { + return GitErrors.RepositoryNotFound(repo); + } + + if (context.Namespace is { } ns) + { + return GitErrors.NamespaceNotFound(ns); + } + + return GitErrors.ProviderRejected(Provider, statusCode, detail); + + case 409: + case 422 when LooksLikeConflict(detail): + return GitErrors.Conflict(context.ConflictResource ?? detail); + + default: + return GitErrors.ProviderRejected(Provider, statusCode, detail); + } + } + + /// + /// Maps an Octokit exception onto a neutral error, honoring rate-limit resets + /// and treating "name already exists" validation failures as conflicts. + /// + public static Error FromException(Exception exception, GitRestErrorContext context) + { + switch (exception) + { + case RateLimitExceededException rate: + return GitErrors.Throttled(RetryAfter(rate.Reset)); + + case SecondaryRateLimitExceededException: + return GitErrors.Throttled(null); + + case AbuseException abuse: + return GitErrors.Throttled( + abuse.RetryAfterSeconds is > 0 ? TimeSpan.FromSeconds(abuse.RetryAfterSeconds.Value) : null); + + case AuthorizationException: + return GitErrors.AuthenticationFailed(Provider, exception.Message); + + case NotFoundException: + if (context.RepositoryFullName is { } repo) + { + return GitErrors.RepositoryNotFound(repo); + } + + if (context.Namespace is { } ns) + { + return GitErrors.NamespaceNotFound(ns); + } + + return GitErrors.ProviderRejected(Provider, 404, exception.Message); + + case ApiValidationException validation when LooksLikeConflict(validation.Message) + || LooksLikeConflict(DescribeValidation(validation)): + return GitErrors.Conflict(context.ConflictResource ?? validation.Message); + + case ApiException api: + return GitErrors.ProviderRejected(Provider, (int)api.StatusCode, api.Message); + + default: + return GitErrors.ProviderRejected(Provider, 0, exception.Message); + } + } + + private static TimeSpan? RetryAfter(DateTimeOffset reset) + { + var delta = reset - DateTimeOffset.UtcNow; + return delta > TimeSpan.Zero ? delta : null; + } + + private static bool LooksLikeConflict(string? message) => + message is not null && message.Contains("already exists", StringComparison.OrdinalIgnoreCase); + + private static string DescribeValidation(ApiValidationException validation) => + validation.ApiError?.Errors is { Count: > 0 } errors + ? string.Join("; ", errors.Select(e => $"{e.Field} {e.Code} {e.Message}")) + : string.Empty; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs new file mode 100644 index 0000000..bfdbfc7 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json.Serialization; + +namespace Compendium.Adapters.GitHub.Http; + +/// A GitHub account (organization or user) as returned by the REST API. +internal sealed class GitHubAccountDto +{ + [JsonPropertyName("login")] + public string Login { get; init; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// Maps the provider account type onto the neutral enum (defaults to organization). + public GitAccountType ToAccountType() => + string.Equals(Type, "User", StringComparison.OrdinalIgnoreCase) + ? GitAccountType.User + : GitAccountType.Organization; +} + +/// A GitHub App installation as returned by the REST API. +internal sealed class GitHubInstallationDto +{ + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("account")] + public GitHubAccountDto? Account { get; init; } + + [JsonPropertyName("suspended_at")] + public DateTimeOffset? SuspendedAt { get; init; } + + /// Maps onto the neutral installation info, defaulting an absent account to an org. + public GitInstallationInfo ToInstallationInfo() => new( + Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + Account?.Login ?? string.Empty, + Account?.ToAccountType() ?? GitAccountType.Organization, + SuspendedAt is not null); +} + +/// A GitHub Actions public key (used to seal secrets before upload). +internal sealed class GitHubPublicKeyDto +{ + [JsonPropertyName("key_id")] + public string KeyId { get; init; } = string.Empty; + + [JsonPropertyName("key")] + public string Key { get; init; } = string.Empty; +} + +/// A GitHub deployment environment as returned by the REST API. +internal sealed class GitHubEnvironmentDto +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; init; } +} + +/// The list envelope returned by GET /repos/{o}/{r}/environments. +internal sealed class GitHubEnvironmentListDto +{ + [JsonPropertyName("environments")] + public List Environments { get; init; } = []; +} + +/// A repository ruleset as returned by the rulesets REST API. +internal sealed class GitHubRulesetDto +{ + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; +} + +/// The minimal repository shape needed to resolve a numeric repository id. +internal sealed class GitHubRepositoryIdDto +{ + [JsonPropertyName("id")] + public long Id { get; init; } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs new file mode 100644 index 0000000..c9a0296 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs @@ -0,0 +1,198 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Compendium.Adapters.GitHub.Http; + +/// +/// A thin typed HTTP helper for the GitHub REST endpoints Octokit lags on +/// (Actions variables, repository rulesets, deployment environments, environment +/// and organization secrets, installation lookup). Sends bearer-authenticated +/// requests against a normalized base URL, deserializes JSON, and maps every +/// non-success response onto the neutral via +/// . Stateless — safe as a singleton. +/// +internal sealed class GitHubRestExecutor +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IHttpClientFactory _httpClientFactory; + + public GitHubRestExecutor(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + /// Sends a GET and deserializes the JSON body into . + public Task> GetAsync( + Uri baseUrl, string token, string path, GitRestErrorContext context, CancellationToken cancellationToken) + => SendWithBodyAsync(HttpMethod.Get, baseUrl, token, path, body: null, context, cancellationToken); + + /// Sends a request with an optional JSON body and deserializes the JSON response. + public Task> SendWithBodyAsync( + HttpMethod method, + Uri baseUrl, + string token, + string path, + object? body, + GitRestErrorContext context, + CancellationToken cancellationToken) + => ExecuteAsync(method, baseUrl, token, path, body, context, readBody: true, cancellationToken, + static async (response, ct) => + { + var value = await response.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false); + return value is null + ? Result.Failure(Error.Failure( + "GitHub.EmptyResponse", "GitHub returned an empty response body where content was expected.")) + : Result.Success(value); + }); + + /// Sends a request with an optional JSON body and ignores the response body. + public async Task SendAsync( + HttpMethod method, + Uri baseUrl, + string token, + string path, + object? body, + GitRestErrorContext context, + CancellationToken cancellationToken) + { + var result = await ExecuteAsync( + method, baseUrl, token, path, body, context, readBody: false, cancellationToken, + static (_, _) => Task.FromResult(Result.Success(null))).ConfigureAwait(false); + return result.IsSuccess ? Result.Success() : Result.Failure(result.Error); + } + + /// + /// Sends a DELETE that treats a 404 as success (idempotent delete of an + /// already-absent resource). + /// + public async Task DeleteIdempotentAsync( + Uri baseUrl, string token, string path, GitRestErrorContext context, CancellationToken cancellationToken) + { + using var response = await SendRawAsync(HttpMethod.Delete, baseUrl, token, path, body: null, cancellationToken) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound) + { + return Result.Success(); + } + + return Result.Failure(await MapFailureAsync(response, context, cancellationToken).ConfigureAwait(false)); + } + + private async Task> ExecuteAsync( + HttpMethod method, + Uri baseUrl, + string token, + string path, + object? body, + GitRestErrorContext context, + bool readBody, + CancellationToken cancellationToken, + Func>> onSuccess) + { + try + { + using var response = await SendRawAsync(method, baseUrl, token, path, body, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return Result.Failure(await MapFailureAsync(response, context, cancellationToken).ConfigureAwait(false)); + } + + if (!readBody || response.StatusCode == HttpStatusCode.NoContent) + { + return Result.Success(default!); + } + + return await onSuccess(response, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + return Result.Failure(Error.Failure( + "GitHub.NetworkError", $"HTTP error calling GitHub: {ex.Message}")); + } + catch (JsonException ex) + { + return Result.Failure(Error.Failure( + "GitHub.MalformedResponse", $"GitHub returned a response that could not be parsed: {ex.Message}")); + } + } + + private async Task SendRawAsync( + HttpMethod method, Uri baseUrl, string token, string path, object? body, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, new Uri(GitHubDefaults.EnsureTrailingSlash(baseUrl), path)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", GitHubDefaults.ApiVersion); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue(GitHubDefaults.ProductName, "1.0")); + if (body is not null) + { + request.Content = JsonContent.Create(body, options: JsonOptions); + } + + var http = _httpClientFactory.CreateClient(GitHubDefaults.HttpClientName); + return await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private static async Task MapFailureAsync( + HttpResponseMessage response, GitRestErrorContext context, CancellationToken cancellationToken) + { + var detail = await ReadDetailAsync(response, cancellationToken).ConfigureAwait(false); + var (retryAfter, rateLimited) = ReadRateLimit(response); + return GitHubErrorMapper.FromStatus((int)response.StatusCode, context, detail, retryAfter, rateLimited); + } + + private static async Task ReadDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase ?? string.Empty : Truncate(body, 500); + } + catch (HttpRequestException) + { + return response.ReasonPhrase ?? string.Empty; + } + } + + private static (TimeSpan? RetryAfter, bool RateLimited) ReadRateLimit(HttpResponseMessage response) + { + if (response.Headers.RetryAfter?.Delta is { } delta) + { + return (delta, true); + } + + if (response.Headers.TryGetValues("x-ratelimit-remaining", out var remainingValues) + && remainingValues.FirstOrDefault() == "0") + { + if (response.Headers.TryGetValues("x-ratelimit-reset", out var resetValues) + && long.TryParse(resetValues.FirstOrDefault(), out var resetUnix)) + { + var reset = DateTimeOffset.FromUnixTimeSeconds(resetUnix) - DateTimeOffset.UtcNow; + return (reset > TimeSpan.Zero ? reset : null, true); + } + + return (null, true); + } + + return (null, false); + } + + private static string Truncate(string value, int max) => + value.Length <= max ? value : value[..max]; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitRestErrorContext.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitRestErrorContext.cs new file mode 100644 index 0000000..1b681d3 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitRestErrorContext.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Http; + +/// +/// Tells the error mapper how to interpret a provider failure for the operation +/// in flight: what a 404 means (a missing repository vs. a missing namespace) +/// and what resource name a 409/422 conflict refers to. +/// +internal sealed record GitRestErrorContext +{ + /// Gets the repository this operation targeted; a 404 maps to Git.RepositoryNotFound. + public string? RepositoryFullName { get; init; } + + /// Gets the namespace this operation targeted; a 404 maps to Git.NamespaceNotFound. + public string? Namespace { get; init; } + + /// Gets the resource name a conflict (409/422 "already exists") refers to. + public string? ConflictResource { get; init; } + + /// An empty context: 404 falls through to a generic provider-rejected error. + public static readonly GitRestErrorContext None = new(); + + /// Builds a context for a repository-scoped operation. + public static GitRestErrorContext ForRepository(GitRepositoryRef repository) => + new() { RepositoryFullName = repository.FullName, ConflictResource = repository.FullName }; + + /// Builds a context for a namespace-scoped operation. + public static GitRestErrorContext ForNamespace(string @namespace) => + new() { Namespace = @namespace, ConflictResource = @namespace }; +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/README.md b/src/Adapters/Compendium.Adapters.GitHub/README.md new file mode 100644 index 0000000..85feaa8 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/README.md @@ -0,0 +1,56 @@ +# Compendium.Adapters.GitHub + +A GitHub adapter for the Compendium Framework: a concrete `IGitServer` implementing +the [`Compendium.Abstractions.Git`](../../Abstractions/Compendium.Abstractions.Git) ports +against **github.com** and **GitHub Enterprise Server**. + +## What it does + +- **GitHub App auth** — signs RS256 App JWTs and mints short-lived installation + access tokens, caching them per installation and refreshing before expiry. + Also passes through caller-supplied service-account, personal-access, and OAuth + tokens. +- **Repositories** — create from a template, read metadata/contents/commits/branches, + create tags, publish releases. +- **CI configuration** — repository, organization, and deployment-environment + Actions **secrets** (libsodium sealed-box encrypted) and **variables**. +- **Pipelines** — trigger workflows (`workflow_dispatch`) and read run status. +- **Environments** — create/update/list deployment environments. +- **Branch policies** — GitHub **repository rulesets** (not legacy branch protection). +- **Access control** — teams, team membership, team/user repository roles. +- **Webhooks** — manage outbound subscriptions; verify and parse inbound deliveries + (fail-closed HMAC-SHA256) into neutral events. + +See [CAPABILITIES.md](CAPABILITIES.md) for the full support matrix and its limitations. + +## Registration + +```csharp +services.AddGitHubGitServer(options => +{ + options.DefaultApp.AppId = configuration["GitHub:AppId"]!; + options.DefaultApp.AppSlug = configuration["GitHub:AppSlug"]!; + options.DefaultApp.PrivateKeyPem = secretStore["GitHub:PrivateKey"]; + options.DefaultApp.WebhookSecret = secretStore["GitHub:WebhookSecret"]; +}); +``` + +Consumers resolve `IEnumerable` and dispatch on `Provider == "github"`, +or resolve a single concern-scoped port (`IGitRepositoryService`, `IGitCredentialBroker`, …). +Every operation takes a `GitConnection` explicitly — the adapter is a stateless +singleton and one instance serves any number of tenants. + +## Connections + +| Credential | Auth | +|---|---| +| `GitCredential.AppInstallation` | Mints an installation token from the configured App (`AppKey` selects a registration). | +| `GitCredential.ServiceAccountToken` / `PersonalAccessToken` | Passed through as a bearer token. | +| `GitCredential.OAuthAccessToken` | Passed through (8-hour reported lifetime). | + +Set `GitConnection.ServerUrl` to a GitHub Enterprise Server API base +(`https://ghes.example.com/api/v3`) to target GHES; leave it `null` for github.com. + +## License + +MIT. See [LICENSE](../../../LICENSE). diff --git a/src/Adapters/Compendium.Adapters.GitHub/Security/GitHubSecretSealer.cs b/src/Adapters/Compendium.Adapters.GitHub/Security/GitHubSecretSealer.cs new file mode 100644 index 0000000..9ff4101 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Security/GitHubSecretSealer.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text; + +namespace Compendium.Adapters.GitHub.Security; + +/// +/// Encrypts CI secret values for GitHub Actions using a libsodium sealed box +/// (crypto_box_seal) against the target's Actions public key — the exact scheme +/// GitHub requires for repository, organization, and environment secrets. +/// Secrets are write-only on GitHub: this only ever encrypts, never decrypts. +/// +internal sealed class GitHubSecretSealer +{ + /// + /// Seals against a base64 Actions public key, + /// returning the base64 ciphertext to place in the encrypted_value + /// field of a set-secret request. + /// + /// The plaintext secret value. + /// The target's Actions public key (base64). + /// The base64-encoded sealed box. + public string Seal(string secretValue, string base64PublicKey) + { + ArgumentNullException.ThrowIfNull(secretValue); + ArgumentException.ThrowIfNullOrWhiteSpace(base64PublicKey); + + var publicKeyBytes = Convert.FromBase64String(base64PublicKey); + var secretBytes = Encoding.UTF8.GetBytes(secretValue); + var sealedBytes = Sodium.SealedPublicKeyBox.Create(secretBytes, publicKeyBytes); + return Convert.ToBase64String(sealedBytes); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubAccessControlService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubAccessControlService.cs new file mode 100644 index 0000000..63e656e --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubAccessControlService.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Http; +using Octokit; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Teams and repository access on an organization, backed by Octokit. Only +/// meaningful for organization namespaces — GitHub rejects team operations on +/// user accounts, which surfaces as a mapped provider error. The neutral +/// repository roles map onto GitHub's pull/triage/push/maintain/admin. +/// +internal sealed class GitHubAccessControlService : IGitAccessControlService +{ + private readonly IGitHubClientProvider _clients; + + public GitHubAccessControlService(IGitHubClientProvider clients) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + } + + /// + public async Task> EnsureTeamAsync( + GitConnection connection, string @namespace, EnsureGitTeam request, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(@namespace); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.TeamsAndPermissions); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + return await ExecuteAsync(connection, GitRestErrorContext.ForNamespace(@namespace), async client => + { + var slug = Slugify(request.Name); + try + { + var existing = await client.Organization.Team.GetByName(@namespace, slug).ConfigureAwait(false); + return Result.Success(new GitTeam(existing.Slug, existing.Name)); + } + catch (NotFoundException) + { + var created = await client.Organization.Team + .Create(@namespace, new NewTeam(request.Name) { Description = request.Description }) + .ConfigureAwait(false); + return Result.Success(new GitTeam(created.Slug, created.Name)); + } + }).ConfigureAwait(false); + } + + /// + public Task AddTeamMemberAsync( + GitConnection connection, string @namespace, string teamSlug, string username, GitTeamRole role, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(@namespace); + ArgumentException.ThrowIfNullOrWhiteSpace(teamSlug); + ArgumentException.ThrowIfNullOrWhiteSpace(username); + + return ExecuteUnitAsync(connection, GitRestErrorContext.ForNamespace(@namespace), async client => + { + var team = await client.Organization.Team.GetByName(@namespace, teamSlug).ConfigureAwait(false); + await client.Organization.Team.AddOrEditMembership( + team.Id, username, new UpdateTeamMembership(role == GitTeamRole.Maintainer ? TeamRole.Maintainer : TeamRole.Member)) + .ConfigureAwait(false); + }); + } + + /// + public Task SetTeamRepositoryRoleAsync( + GitConnection connection, string @namespace, string teamSlug, GitRepositoryRef repository, GitRepositoryRole role, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(@namespace); + ArgumentException.ThrowIfNullOrWhiteSpace(teamSlug); + ArgumentNullException.ThrowIfNull(repository); + + return ExecuteUnitAsync(connection, GitRestErrorContext.ForRepository(repository), client => + client.Organization.Team.AddOrUpdateTeamRepositoryPermissions( + @namespace, teamSlug, repository.Namespace, repository.Name, MapPermission(role))); + } + + /// + public Task SetUserRepositoryRoleAsync( + GitConnection connection, GitRepositoryRef repository, string username, GitRepositoryRole role, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(username); + + return ExecuteUnitAsync(connection, GitRestErrorContext.ForRepository(repository), client => + client.Repository.Collaborator.Add( + repository.Namespace, repository.Name, username, new CollaboratorRequest(MapPermission(role)))); + } + + /// + public Task RemoveUserFromRepositoryAsync( + GitConnection connection, GitRepositoryRef repository, string username, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(username); + + return ExecuteUnitAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + try + { + await client.Repository.Collaborator.Delete(repository.Namespace, repository.Name, username) + .ConfigureAwait(false); + } + catch (NotFoundException) + { + // Idempotent: removing access that is already absent succeeds. + } + }); + } + + private static string MapPermission(GitRepositoryRole role) => role switch + { + GitRepositoryRole.Read => "pull", + GitRepositoryRole.Triage => "triage", + GitRepositoryRole.Write => "push", + GitRepositoryRole.Maintain => "maintain", + GitRepositoryRole.Admin => "admin", + _ => "pull", + }; + + private static string Slugify(string name) => + name.Trim().ToLowerInvariant().Replace(' ', '-'); + + private async Task> ExecuteAsync( + GitConnection connection, GitRestErrorContext context, Func>> operation) + { + ArgumentNullException.ThrowIfNull(connection); + + var clientResult = await _clients.GetClientAsync(connection, CancellationToken.None).ConfigureAwait(false); + if (clientResult.IsFailure) + { + return Result.Failure(clientResult.Error); + } + + try + { + return await operation(clientResult.Value).ConfigureAwait(false); + } + catch (ApiException ex) + { + return Result.Failure(GitHubErrorMapper.FromException(ex, context)); + } + } + + private async Task ExecuteUnitAsync( + GitConnection connection, GitRestErrorContext context, Func operation) + { + var result = await ExecuteAsync(connection, context, async client => + { + await operation(client).ConfigureAwait(false); + return Result.Success(null); + }).ConfigureAwait(false); + return result.IsSuccess ? Result.Success() : Result.Failure(result.Error); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubBranchPolicyService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubBranchPolicyService.cs new file mode 100644 index 0000000..67adba6 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubBranchPolicyService.cs @@ -0,0 +1,200 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Globalization; +using Compendium.Adapters.GitHub.Auth; +using Compendium.Adapters.GitHub.Http; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Branch protection backed by GitHub repository rulesets (not legacy branch +/// protection). Each neutral policy maps to one ruleset named +/// compendium:{pattern}; finds an existing ruleset +/// by that name and updates it, so applying the same pattern twice is idempotent. +/// +internal sealed class GitHubBranchPolicyService : IGitBranchPolicyService +{ + private const string NamePrefix = "compendium:"; + private const int RepositoryAdminRoleId = 5; + + private readonly GitHubCredentialBroker _broker; + private readonly GitHubRestExecutor _rest; + + public GitHubBranchPolicyService(GitHubCredentialBroker broker, GitHubRestExecutor rest) + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + _rest = rest ?? throw new ArgumentNullException(nameof(rest)); + } + + /// + public async Task> ApplyAsync( + GitConnection connection, GitRepositoryRef repository, GitBranchPolicyRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.BranchPolicies); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var context = GitRestErrorContext.ForRepository(repository); + var name = NamePrefix + request.Pattern; + var basePath = $"repos/{repository.Namespace}/{repository.Name}/rulesets"; + + var existing = await _rest.GetAsync>( + auth.Value.ApiBase, auth.Value.Token, basePath, context, cancellationToken).ConfigureAwait(false); + if (existing.IsFailure) + { + return Result.Failure(existing.Error); + } + + var match = existing.Value.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.Ordinal)); + var payload = BuildPayload(request, name); + + var applied = match is null + ? await _rest.SendWithBodyAsync( + HttpMethod.Post, auth.Value.ApiBase, auth.Value.Token, basePath, payload, context, cancellationToken) + .ConfigureAwait(false) + : await _rest.SendWithBodyAsync( + HttpMethod.Put, auth.Value.ApiBase, auth.Value.Token, $"{basePath}/{match.Id}", payload, context, cancellationToken) + .ConfigureAwait(false); + + return applied.IsFailure + ? Result.Failure(applied.Error) + : Result.Success(new GitBranchPolicy(applied.Value.Id.ToString(CultureInfo.InvariantCulture), request.Pattern)); + } + + /// + public async Task RemoveAsync( + GitConnection connection, GitRepositoryRef repository, string policyId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(policyId); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var path = $"repos/{repository.Namespace}/{repository.Name}/rulesets/{Uri.EscapeDataString(policyId)}"; + return await _rest.DeleteIdempotentAsync( + auth.Value.ApiBase, auth.Value.Token, path, GitRestErrorContext.ForRepository(repository), cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task>> ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure>(auth.Error); + } + + var path = $"repos/{repository.Namespace}/{repository.Name}/rulesets"; + var result = await _rest.GetAsync>( + auth.Value.ApiBase, auth.Value.Token, path, GitRestErrorContext.ForRepository(repository), cancellationToken) + .ConfigureAwait(false); + + if (result.IsFailure) + { + return Result.Failure>(result.Error); + } + + IReadOnlyList policies = result.Value + .Select(r => new GitBranchPolicy(r.Id.ToString(CultureInfo.InvariantCulture), PatternFromName(r.Name))) + .ToList(); + return Result.Success(policies); + } + + private static string PatternFromName(string name) => + name.StartsWith(NamePrefix, StringComparison.Ordinal) ? name[NamePrefix.Length..] : name; + + private static object BuildPayload(GitBranchPolicyRequest request, string name) + { + var rules = new List(); + + if (request.RequirePullRequest) + { + rules.Add(new + { + type = "pull_request", + parameters = new + { + required_approving_review_count = request.RequiredApprovals, + dismiss_stale_reviews_on_push = request.DismissStaleApprovals, + require_code_owner_review = false, + require_last_push_approval = false, + required_review_thread_resolution = false, + }, + }); + } + + if (request.RequiredStatusChecks is { Count: > 0 } checks) + { + rules.Add(new + { + type = "required_status_checks", + parameters = new + { + required_status_checks = checks.Select(c => new { context = c }).ToArray(), + strict_required_status_checks_policy = false, + }, + }); + } + + if (request.BlockForcePush) + { + rules.Add(new { type = "non_fast_forward" }); + } + + if (request.BlockDeletion) + { + rules.Add(new { type = "deletion" }); + } + + if (request.RequireLinearHistory) + { + rules.Add(new { type = "required_linear_history" }); + } + + var payload = new Dictionary(StringComparer.Ordinal) + { + ["name"] = name, + ["target"] = "branch", + ["enforcement"] = "active", + ["conditions"] = new + { + ref_name = new { include = new[] { $"refs/heads/{request.Pattern}" }, exclude = Array.Empty() }, + }, + ["rules"] = rules, + }; + + if (!request.EnforceForAdmins) + { + payload["bypass_actors"] = new[] + { + new { actor_id = RepositoryAdminRoleId, actor_type = "RepositoryRole", bypass_mode = "always" }, + }; + } + + return payload; + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubCiConfigurationService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubCiConfigurationService.cs new file mode 100644 index 0000000..5ed42f2 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubCiConfigurationService.cs @@ -0,0 +1,235 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Auth; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Http; +using Compendium.Adapters.GitHub.Security; +using Microsoft.Extensions.Options; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// GitHub Actions secrets and variables at repository, organization, and +/// deployment-environment scope, over the REST API. Secrets are write-only: +/// values are sealed against the target's Actions public key (libsodium sealed +/// box) before upload and never read back. Environment-scoped calls resolve the +/// repository's numeric id, which the environment endpoints key on. +/// +internal sealed class GitHubCiConfigurationService : IGitCiConfigurationService +{ + private readonly GitHubCredentialBroker _broker; + private readonly GitHubRestExecutor _rest; + private readonly GitHubSecretSealer _sealer; + + public GitHubCiConfigurationService( + GitHubCredentialBroker broker, + GitHubRestExecutor rest, + GitHubSecretSealer sealer) + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + _rest = rest ?? throw new ArgumentNullException(nameof(rest)); + _sealer = sealer ?? throw new ArgumentNullException(nameof(sealer)); + } + + /// + public async Task SetSecretsAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary secrets, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(secrets); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var paths = await ResolvePathsAsync(auth.Value, scope, cancellationToken).ConfigureAwait(false); + if (paths.IsFailure) + { + return Result.Failure(paths.Error); + } + + var publicKey = await _rest.GetAsync( + auth.Value.ApiBase, auth.Value.Token, paths.Value.PublicKeyPath, GitRestErrorContext.None, cancellationToken) + .ConfigureAwait(false); + if (publicKey.IsFailure) + { + return Result.Failure(publicKey.Error); + } + + foreach (var (name, value) in secrets) + { + var body = new Dictionary(StringComparer.Ordinal) + { + ["encrypted_value"] = _sealer.Seal(value, publicKey.Value.Key), + ["key_id"] = publicKey.Value.KeyId, + }; + if (paths.Value.OrgVisibility is { } visibility) + { + body["visibility"] = visibility; + } + + var set = await _rest.SendAsync( + HttpMethod.Put, auth.Value.ApiBase, auth.Value.Token, $"{paths.Value.SecretsBase}/{Uri.EscapeDataString(name)}", + body, GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + if (set.IsFailure) + { + return set; + } + } + + return Result.Success(); + } + + /// + public async Task DeleteSecretAsync( + GitConnection connection, GitConfigurationScope scope, string name, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var paths = await ResolvePathsAsync(auth.Value, scope, cancellationToken).ConfigureAwait(false); + if (paths.IsFailure) + { + return Result.Failure(paths.Error); + } + + return await _rest.DeleteIdempotentAsync( + auth.Value.ApiBase, auth.Value.Token, $"{paths.Value.SecretsBase}/{Uri.EscapeDataString(name)}", + GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SetVariablesAsync( + GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary variables, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(variables); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var paths = await ResolvePathsAsync(auth.Value, scope, cancellationToken).ConfigureAwait(false); + if (paths.IsFailure) + { + return Result.Failure(paths.Error); + } + + foreach (var (name, value) in variables) + { + var createBody = new Dictionary(StringComparer.Ordinal) { ["name"] = name, ["value"] = value }; + if (paths.Value.OrgVisibility is { } visibility) + { + createBody["visibility"] = visibility; + } + + var created = await _rest.SendAsync( + HttpMethod.Post, auth.Value.ApiBase, auth.Value.Token, paths.Value.VariablesBase, createBody, + new GitRestErrorContext { ConflictResource = name }, cancellationToken).ConfigureAwait(false); + + if (created.IsSuccess) + { + continue; + } + + if (created.Error.Code != $"{GitErrors.Prefix}.Conflict") + { + return created; + } + + // Already exists — update it (GitHub variable updates PATCH by name). + var updated = await _rest.SendAsync( + HttpMethod.Patch, auth.Value.ApiBase, auth.Value.Token, + $"{paths.Value.VariablesBase}/{Uri.EscapeDataString(name)}", + new Dictionary(StringComparer.Ordinal) { ["name"] = name, ["value"] = value }, + GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + if (updated.IsFailure) + { + return updated; + } + } + + return Result.Success(); + } + + /// + public async Task DeleteVariableAsync( + GitConnection connection, GitConfigurationScope scope, string name, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var paths = await ResolvePathsAsync(auth.Value, scope, cancellationToken).ConfigureAwait(false); + if (paths.IsFailure) + { + return Result.Failure(paths.Error); + } + + return await _rest.DeleteIdempotentAsync( + auth.Value.ApiBase, auth.Value.Token, $"{paths.Value.VariablesBase}/{Uri.EscapeDataString(name)}", + GitRestErrorContext.None, cancellationToken).ConfigureAwait(false); + } + + private async Task> ResolvePathsAsync( + AuthorizedConnection auth, GitConfigurationScope scope, CancellationToken cancellationToken) + { + switch (scope) + { + case GitConfigurationScope.Repository r: + { + var b = $"repos/{r.Ref.Namespace}/{r.Ref.Name}/actions"; + return Result.Success(new ScopePaths($"{b}/secrets", $"{b}/variables", $"{b}/secrets/public-key", null)); + } + + case GitConfigurationScope.Namespace n: + { + var b = $"orgs/{Uri.EscapeDataString(n.Name)}/actions"; + return Result.Success(new ScopePaths($"{b}/secrets", $"{b}/variables", $"{b}/secrets/public-key", "all")); + } + + case GitConfigurationScope.Environment e: + { + var repoId = await _rest.GetAsync( + auth.ApiBase, auth.Token, $"repos/{e.Ref.Namespace}/{e.Ref.Name}", + GitRestErrorContext.ForRepository(e.Ref), cancellationToken).ConfigureAwait(false); + if (repoId.IsFailure) + { + return Result.Failure(repoId.Error); + } + + var env = Uri.EscapeDataString(e.EnvironmentName); + var b = $"repositories/{repoId.Value.Id}/environments/{env}"; + return Result.Success(new ScopePaths($"{b}/secrets", $"{b}/variables", $"{b}/secrets/public-key", null)); + } + + default: + return Result.Failure(Error.Failure( + "GitHub.UnknownScope", $"Unknown configuration scope '{scope.GetType().Name}'.")); + } + } + + private sealed record ScopePaths(string SecretsBase, string VariablesBase, string PublicKeyPath, string? OrgVisibility); +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubEnvironmentService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubEnvironmentService.cs new file mode 100644 index 0000000..5293a3a --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubEnvironmentService.cs @@ -0,0 +1,103 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Auth; +using Compendium.Adapters.GitHub.Http; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// GitHub deployment environments over the REST API. PUT .../environments/{name} +/// is create-or-update, so is naturally idempotent. +/// +internal sealed class GitHubEnvironmentService : IGitEnvironmentService +{ + private readonly GitHubCredentialBroker _broker; + private readonly GitHubRestExecutor _rest; + + public GitHubEnvironmentService(GitHubCredentialBroker broker, GitHubRestExecutor rest) + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + _rest = rest ?? throw new ArgumentNullException(nameof(rest)); + } + + /// + public async Task> EnsureAsync( + GitConnection connection, GitRepositoryRef repository, EnsureGitEnvironment request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.DeploymentEnvironments); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var path = $"repos/{repository.Namespace}/{repository.Name}/environments/{Uri.EscapeDataString(request.Name)}"; + var result = await _rest.SendWithBodyAsync( + HttpMethod.Put, auth.Value.ApiBase, auth.Value.Token, path, new { }, + GitRestErrorContext.ForRepository(repository), cancellationToken).ConfigureAwait(false); + + return result.IsFailure + ? Result.Failure(result.Error) + : Result.Success(new GitDeploymentEnvironment(request.Name, result.Value.HtmlUrl)); + } + + /// + public async Task DeleteAsync( + GitConnection connection, GitRepositoryRef repository, string environmentName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure(auth.Error); + } + + var path = $"repos/{repository.Namespace}/{repository.Name}/environments/{Uri.EscapeDataString(environmentName)}"; + return await _rest.DeleteIdempotentAsync( + auth.Value.ApiBase, auth.Value.Token, path, GitRestErrorContext.ForRepository(repository), cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task>> ListAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + + var auth = await _broker.AuthorizeAsync(connection, cancellationToken).ConfigureAwait(false); + if (auth.IsFailure) + { + return Result.Failure>(auth.Error); + } + + var path = $"repos/{repository.Namespace}/{repository.Name}/environments"; + var result = await _rest.GetAsync( + auth.Value.ApiBase, auth.Value.Token, path, GitRestErrorContext.ForRepository(repository), cancellationToken) + .ConfigureAwait(false); + + if (result.IsFailure) + { + return Result.Failure>(result.Error); + } + + IReadOnlyList environments = result.Value.Environments + .Select(e => new GitDeploymentEnvironment(e.Name, e.HtmlUrl)).ToList(); + return Result.Success(environments); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubNamespaceProvisioner.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubNamespaceProvisioner.cs new file mode 100644 index 0000000..72b8155 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubNamespaceProvisioner.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Namespace provisioning is unreachable on github.com with App credentials — +/// creating an organization needs an enterprise-owner user token. This provider +/// declares as +/// and always fails with the standard +/// Git.CapabilityNotSupported rather than attempting the call. +/// +internal sealed class GitHubNamespaceProvisioner : IGitNamespaceProvisioner +{ + /// + public Task> CreateNamespaceAsync( + GitConnection connection, CreateGitNamespace request, CancellationToken cancellationToken = default) + { + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.NamespaceProvisioning); + return Task.FromResult(Result.Failure(guard.Error)); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineService.cs new file mode 100644 index 0000000..3ceb149 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineService.cs @@ -0,0 +1,135 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Globalization; +using Compendium.Adapters.GitHub.Http; +using Octokit; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// CI pipeline operations backed by GitHub Actions: dispatch a workflow and read +/// run status. workflow_dispatch is fire-and-forget — +/// returns a handle with a run id and callers correlate the +/// created run via . +/// +internal sealed class GitHubPipelineService : IGitPipelineService +{ + private readonly IGitHubClientProvider _clients; + + public GitHubPipelineService(IGitHubClientProvider clients) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + } + + /// + public async Task> TriggerAsync( + GitConnection connection, GitRepositoryRef repository, TriggerGitPipeline request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.PipelineTrigger); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + return await ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var dispatch = new CreateWorkflowDispatch(request.Reference); + if (request.Inputs is { Count: > 0 } inputs) + { + dispatch.Inputs = inputs.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value); + } + + await client.Actions.Workflows + .CreateDispatch(repository.Namespace, repository.Name, request.Pipeline, dispatch).ConfigureAwait(false); + + // workflow_dispatch returns 204 with no run id — see CAPABILITIES.md (PipelineTrigger=Partial). + return Result.Success(new GitPipelineRunHandle(null)); + }).ConfigureAwait(false); + } + + /// + public async Task> GetRunAsync( + GitConnection connection, GitRepositoryRef repository, string runId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + + if (!long.TryParse(runId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericRunId)) + { + return Result.Failure(Error.Validation( + "GitHub.InvalidRunId", $"'{runId}' is not a valid GitHub Actions run id.")); + } + + return await ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var run = await client.Actions.Workflows.Runs + .Get(repository.Namespace, repository.Name, numericRunId).ConfigureAwait(false); + return Result.Success(Map(run)); + }).ConfigureAwait(false); + } + + /// + public Task>> ListRunsAsync( + GitConnection connection, GitRepositoryRef repository, ListGitPipelineRuns query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(query); + + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var response = string.IsNullOrWhiteSpace(query.Pipeline) + ? await client.Actions.Workflows.Runs.List(repository.Namespace, repository.Name).ConfigureAwait(false) + : await client.Actions.Workflows.Runs + .ListByWorkflow(repository.Namespace, repository.Name, query.Pipeline).ConfigureAwait(false); + + IReadOnlyList runs = response.WorkflowRuns + .Where(r => string.IsNullOrWhiteSpace(query.Reference) || r.HeadBranch == query.Reference) + .OrderByDescending(r => r.CreatedAt) + .Take(query.Limit) + .Select(Map) + .ToList(); + return Result.Success(runs); + }); + } + + private static GitPipelineRun Map(WorkflowRun run) => new( + run.Id.ToString(CultureInfo.InvariantCulture), + run.Name, + GitHubPipelineStatusMapper.Map(run.Status.StringValue, run.Conclusion?.StringValue), + run.HeadBranch, + run.HtmlUrl, + run.CreatedAt); + + private async Task> ExecuteAsync( + GitConnection connection, + GitRestErrorContext context, + Func>> operation) + { + ArgumentNullException.ThrowIfNull(connection); + + var clientResult = await _clients.GetClientAsync(connection, CancellationToken.None).ConfigureAwait(false); + if (clientResult.IsFailure) + { + return Result.Failure(clientResult.Error); + } + + try + { + return await operation(clientResult.Value).ConfigureAwait(false); + } + catch (ApiException ex) + { + return Result.Failure(GitHubErrorMapper.FromException(ex, context)); + } + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineStatusMapper.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineStatusMapper.cs new file mode 100644 index 0000000..eb76b72 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubPipelineStatusMapper.cs @@ -0,0 +1,41 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Maps GitHub Actions workflow-run status and conclusion strings +/// onto the neutral . Shared by the pipeline +/// service (run reads) and the webhook ingestor (workflow_run deliveries). +/// +internal static class GitHubPipelineStatusMapper +{ + /// + /// Maps a run's status and (when completed) conclusion onto the neutral status. + /// + public static GitPipelineStatus Map(string? status, string? conclusion) + { + if (string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)) + { + return conclusion?.ToLowerInvariant() switch + { + "success" => GitPipelineStatus.Succeeded, + "failure" or "timed_out" or "startup_failure" => GitPipelineStatus.Failed, + "cancelled" => GitPipelineStatus.Cancelled, + "skipped" => GitPipelineStatus.Skipped, + _ => GitPipelineStatus.Unknown, + }; + } + + return status?.ToLowerInvariant() switch + { + "queued" or "requested" or "pending" or "waiting" => GitPipelineStatus.Queued, + "in_progress" => GitPipelineStatus.Running, + _ => GitPipelineStatus.Unknown, + }; + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubRepositoryService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubRepositoryService.cs new file mode 100644 index 0000000..9edc37a --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubRepositoryService.cs @@ -0,0 +1,272 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Http; +using Octokit; +using GitCommit = Compendium.Abstractions.Git.Repositories.GitCommit; +using GitTag = Compendium.Abstractions.Git.Repositories.GitTag; +using OctokitRepository = Octokit.Repository; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Repository lifecycle and read operations backed by Octokit: create from +/// template, inspect contents, list commits/branches/tags, create tags and +/// publish releases. +/// +internal sealed class GitHubRepositoryService : IGitRepositoryService +{ + private readonly IGitHubClientProvider _clients; + + public GitHubRepositoryService(IGitHubClientProvider clients) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + } + + /// + public async Task> CreateFromTemplateAsync( + GitConnection connection, CreateRepositoryFromTemplate request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.RepositoryFromTemplate); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + var context = new GitRestErrorContext + { + RepositoryFullName = request.Template.FullName, + ConflictResource = $"{request.Namespace}/{request.Name}", + }; + + return await ExecuteAsync(connection, context, async client => + { + var newRepo = new NewRepositoryFromTemplate(request.Name) + { + Owner = request.Namespace, + Description = request.Description, + Private = request.Private, + }; + + var repo = await client.Repository + .Generate(request.Template.Namespace, request.Template.Name, newRepo).ConfigureAwait(false); + return Result.Success(Map(repo)); + }).ConfigureAwait(false); + } + + /// + public Task> GetAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var repo = await client.Repository.Get(repository.Namespace, repository.Name).ConfigureAwait(false); + return Result.Success(Map(repo)); + }); + } + + /// + public Task>> ListAsync( + GitConnection connection, string @namespace, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(@namespace); + return ExecuteAsync(connection, GitRestErrorContext.ForNamespace(@namespace), async client => + { + IReadOnlyList repos; + try + { + repos = await client.Repository.GetAllForOrg(@namespace).ConfigureAwait(false); + } + catch (NotFoundException) + { + repos = await client.Repository.GetAllForUser(@namespace).ConfigureAwait(false); + } + + IReadOnlyList mapped = repos.Select(Map).ToList(); + return Result.Success(mapped); + }); + } + + /// + public Task> FileExistsAsync( + GitConnection connection, GitRepositoryRef repository, string path, string? gitRef = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + try + { + var contents = string.IsNullOrEmpty(gitRef) + ? await client.Repository.Content.GetAllContents(repository.Namespace, repository.Name, path) + .ConfigureAwait(false) + : await client.Repository.Content.GetAllContentsByRef(repository.Namespace, repository.Name, path, gitRef) + .ConfigureAwait(false); + return Result.Success(contents.Count > 0); + } + catch (NotFoundException) + { + return Result.Success(false); + } + }); + } + + /// + public Task>> ListCommitsAsync( + GitConnection connection, GitRepositoryRef repository, string? reference, int limit, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var request = new CommitRequest(); + if (!string.IsNullOrWhiteSpace(reference)) + { + request.Sha = reference; + } + + var options = new ApiOptions { PageSize = limit, PageCount = 1 }; + var commits = await client.Repository.Commit + .GetAll(repository.Namespace, repository.Name, request, options).ConfigureAwait(false); + + IReadOnlyList mapped = commits.Select(c => new GitCommit( + c.Sha, + c.Commit.Message, + c.Commit.Author?.Name, + c.Commit.Author?.Date, + c.HtmlUrl)).ToList(); + return Result.Success(mapped); + }); + } + + /// + public Task>> ListBranchesAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var branches = await client.Repository.Branch + .GetAll(repository.Namespace, repository.Name).ConfigureAwait(false); + IReadOnlyList mapped = branches + .Select(b => new GitBranch(b.Name, b.Commit.Sha, b.Protected)).ToList(); + return Result.Success(mapped); + }); + } + + /// + public async Task> CreateTagAsync( + GitConnection connection, GitRepositoryRef repository, string tagName, string? commitSha = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.TagsAndReleases); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + return await ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var sha = commitSha ?? await ResolveDefaultBranchHeadAsync(client, repository).ConfigureAwait(false); + var reference = await client.Git.Reference + .Create(repository.Namespace, repository.Name, new NewReference($"refs/tags/{tagName}", sha)) + .ConfigureAwait(false); + return Result.Success(new GitTag(tagName, reference.Object.Sha)); + }).ConfigureAwait(false); + } + + /// + public Task>> ListTagsAsync( + GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + return ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var tags = await client.Repository.GetAllTags(repository.Namespace, repository.Name).ConfigureAwait(false); + IReadOnlyList mapped = tags.Select(t => new GitTag(t.Name, t.Commit.Sha)).ToList(); + return Result.Success(mapped); + }); + } + + /// + public async Task> CreateReleaseAsync( + GitConnection connection, GitRepositoryRef repository, CreateGitRelease request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.TagsAndReleases); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + return await ExecuteAsync(connection, GitRestErrorContext.ForRepository(repository), async client => + { + var newRelease = new NewRelease(request.TagName) + { + Name = request.Title ?? request.TagName, + Body = request.Body, + GenerateReleaseNotes = request.Body is null, + }; + if (!string.IsNullOrWhiteSpace(request.TargetCommitSha)) + { + newRelease.TargetCommitish = request.TargetCommitSha; + } + + var release = await client.Repository.Release + .Create(repository.Namespace, repository.Name, newRelease).ConfigureAwait(false); + return Result.Success(new GitRelease( + release.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + release.TagName, + release.HtmlUrl)); + }).ConfigureAwait(false); + } + + private static GitRepository Map(OctokitRepository repo) => new( + new GitRepositoryRef(repo.Owner.Login, repo.Name), + repo.CloneUrl, + repo.HtmlUrl, + string.IsNullOrWhiteSpace(repo.DefaultBranch) ? "main" : repo.DefaultBranch, + repo.Private); + + private static async Task ResolveDefaultBranchHeadAsync(IGitHubClient client, GitRepositoryRef repository) + { + var repo = await client.Repository.Get(repository.Namespace, repository.Name).ConfigureAwait(false); + var branchName = string.IsNullOrWhiteSpace(repo.DefaultBranch) ? "main" : repo.DefaultBranch; + var branch = await client.Repository.Branch + .Get(repository.Namespace, repository.Name, branchName).ConfigureAwait(false); + return branch.Commit.Sha; + } + + private async Task> ExecuteAsync( + GitConnection connection, + GitRestErrorContext context, + Func>> operation) + { + ArgumentNullException.ThrowIfNull(connection); + + var clientResult = await _clients.GetClientAsync(connection, CancellationToken.None).ConfigureAwait(false); + if (clientResult.IsFailure) + { + return Result.Failure(clientResult.Error); + } + + try + { + return await operation(clientResult.Value).ConfigureAwait(false); + } + catch (ApiException ex) + { + return Result.Failure(GitHubErrorMapper.FromException(ex, context)); + } + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubWebhookService.cs b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubWebhookService.cs new file mode 100644 index 0000000..ca6d4d1 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Services/GitHubWebhookService.cs @@ -0,0 +1,221 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Globalization; +using Compendium.Adapters.GitHub.Http; +using Octokit; + +namespace Compendium.Adapters.GitHub.Services; + +/// +/// Outbound webhook subscriptions on a repository or organization, backed by +/// Octokit. Subscriptions are matched by delivery URL so ensuring the same URL +/// twice updates in place rather than duplicating. +/// +internal sealed class GitHubWebhookService : IGitWebhookService +{ + private const string HookName = "web"; + + private readonly IGitHubClientProvider _clients; + + public GitHubWebhookService(IGitHubClientProvider clients) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + } + + /// + public async Task> EnsureAsync( + GitConnection connection, GitWebhookTarget target, EnsureGitWebhook request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(request); + var guard = GitHubCapabilities.Matrix.EnsureSupported(GitCapability.WebhookManagement); + if (guard.IsFailure) + { + return Result.Failure(guard.Error); + } + + return await ExecuteAsync(connection, Context(target), client => target switch + { + GitWebhookTarget.Repository r => EnsureRepositoryHookAsync(client, r.Ref, request), + GitWebhookTarget.Namespace n => EnsureOrganizationHookAsync(client, n.Name, request), + _ => Task.FromResult(Result.Failure(UnknownTarget(target))), + }).ConfigureAwait(false); + } + + /// + public Task DeleteAsync( + GitConnection connection, GitWebhookTarget target, string subscriptionId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentException.ThrowIfNullOrWhiteSpace(subscriptionId); + + if (!int.TryParse(subscriptionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var hookId)) + { + // A non-numeric id cannot name an existing GitHub hook — nothing to delete. + return Task.FromResult(Result.Success()); + } + + return ExecuteUnitAsync(connection, Context(target), async client => + { + try + { + switch (target) + { + case GitWebhookTarget.Repository r: + await client.Repository.Hooks.Delete(r.Ref.Namespace, r.Ref.Name, hookId).ConfigureAwait(false); + break; + case GitWebhookTarget.Namespace n: + await client.Organization.Hook.Delete(n.Name, hookId).ConfigureAwait(false); + break; + } + } + catch (NotFoundException) + { + // Idempotent: deleting an absent subscription succeeds. + } + }); + } + + /// + public Task>> ListAsync( + GitConnection connection, GitWebhookTarget target, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + + return ExecuteAsync(connection, Context(target), async client => + { + IReadOnlyList subscriptions = target switch + { + GitWebhookTarget.Repository r => + (await client.Repository.Hooks.GetAll(r.Ref.Namespace, r.Ref.Name).ConfigureAwait(false)) + .Select(MapRepositoryHook).Where(s => s is not null).Select(s => s!).ToList(), + GitWebhookTarget.Namespace n => + (await client.Organization.Hook.GetAll(n.Name).ConfigureAwait(false)) + .Select(MapOrganizationHook).Where(s => s is not null).Select(s => s!).ToList(), + _ => [], + }; + return Result.Success(subscriptions); + }); + } + + private static async Task> EnsureRepositoryHookAsync( + IGitHubClient client, GitRepositoryRef repository, EnsureGitWebhook request) + { + var hooks = await client.Repository.Hooks.GetAll(repository.Namespace, repository.Name).ConfigureAwait(false); + var existing = hooks.FirstOrDefault(h => UrlOf(h.Config) == request.Url.ToString()); + var config = BuildConfig(request); + var events = request.Events.ToList(); + + if (existing is not null) + { + var updated = await client.Repository.Hooks.Edit( + repository.Namespace, repository.Name, (int)existing.Id, + new EditRepositoryHook(config) { Events = events, Active = request.Active }).ConfigureAwait(false); + return Result.Success(MapRepositoryHook(updated)!); + } + + var created = await client.Repository.Hooks.Create( + repository.Namespace, repository.Name, + new NewRepositoryHook(HookName, config) { Events = events, Active = request.Active }).ConfigureAwait(false); + return Result.Success(MapRepositoryHook(created)!); + } + + private static async Task> EnsureOrganizationHookAsync( + IGitHubClient client, string @namespace, EnsureGitWebhook request) + { + var hooks = await client.Organization.Hook.GetAll(@namespace).ConfigureAwait(false); + var existing = hooks.FirstOrDefault(h => UrlOf(h.Config) == request.Url.ToString()); + var config = BuildConfig(request); + var events = request.Events.ToList(); + + if (existing is not null) + { + var updated = await client.Organization.Hook.Edit( + @namespace, (int)existing.Id, + new EditOrganizationHook(config) { Events = events, Active = request.Active }).ConfigureAwait(false); + return Result.Success(MapOrganizationHook(updated)!); + } + + var created = await client.Organization.Hook.Create( + @namespace, new NewOrganizationHook(HookName, config) { Events = events, Active = request.Active }) + .ConfigureAwait(false); + return Result.Success(MapOrganizationHook(created)!); + } + + private static Dictionary BuildConfig(EnsureGitWebhook request) => new(StringComparer.Ordinal) + { + ["url"] = request.Url.ToString(), + ["content_type"] = "json", + ["secret"] = request.Secret, + ["insecure_ssl"] = "0", + }; + + private static string? UrlOf(IReadOnlyDictionary? config) => + config is not null && config.TryGetValue("url", out var url) ? url : null; + + private static GitWebhookSubscription? MapRepositoryHook(RepositoryHook hook) + { + var url = UrlOf(hook.Config); + return url is null + ? null + : new GitWebhookSubscription( + hook.Id.ToString(CultureInfo.InvariantCulture), new Uri(url), hook.Events?.ToList() ?? [], hook.Active); + } + + private static GitWebhookSubscription? MapOrganizationHook(OrganizationHook hook) + { + var url = UrlOf(hook.Config); + return url is null + ? null + : new GitWebhookSubscription( + hook.Id.ToString(CultureInfo.InvariantCulture), new Uri(url), hook.Events?.ToList() ?? [], hook.Active); + } + + private static GitRestErrorContext Context(GitWebhookTarget target) => target switch + { + GitWebhookTarget.Repository r => GitRestErrorContext.ForRepository(r.Ref), + GitWebhookTarget.Namespace n => GitRestErrorContext.ForNamespace(n.Name), + _ => GitRestErrorContext.None, + }; + + private static Error UnknownTarget(GitWebhookTarget target) => + Error.Failure("GitHub.UnknownWebhookTarget", $"Unknown webhook target '{target.GetType().Name}'."); + + private async Task> ExecuteAsync( + GitConnection connection, GitRestErrorContext context, Func>> operation) + { + ArgumentNullException.ThrowIfNull(connection); + + var clientResult = await _clients.GetClientAsync(connection, CancellationToken.None).ConfigureAwait(false); + if (clientResult.IsFailure) + { + return Result.Failure(clientResult.Error); + } + + try + { + return await operation(clientResult.Value).ConfigureAwait(false); + } + catch (ApiException ex) + { + return Result.Failure(GitHubErrorMapper.FromException(ex, context)); + } + } + + private async Task ExecuteUnitAsync( + GitConnection connection, GitRestErrorContext context, Func operation) + { + var result = await ExecuteAsync(connection, context, async client => + { + await operation(client).ConfigureAwait(false); + return Result.Success(null); + }).ConfigureAwait(false); + return result.IsSuccess ? Result.Success() : Result.Failure(result.Error); + } +} diff --git a/src/Adapters/Compendium.Adapters.GitHub/Webhooks/GitHubWebhookIngestor.cs b/src/Adapters/Compendium.Adapters.GitHub/Webhooks/GitHubWebhookIngestor.cs new file mode 100644 index 0000000..2f276d1 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.GitHub/Webhooks/GitHubWebhookIngestor.cs @@ -0,0 +1,248 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Compendium.Adapters.GitHub.Services; + +namespace Compendium.Adapters.GitHub.Webhooks; + +/// +/// Verifies and parses inbound GitHub webhook deliveries into the neutral +/// union. Fail-closed: a missing or invalid +/// X-Hub-Signature-256 (HMAC-SHA256 over the raw body, constant-time +/// compared) is rejected before any parsing. Event types the platform does not +/// consume parse to . +/// +internal sealed class GitHubWebhookIngestor : IGitWebhookIngestor +{ + private const string SignatureHeader = "X-Hub-Signature-256"; + private const string EventHeader = "X-GitHub-Event"; + private const string DeliveryHeader = "X-GitHub-Delivery"; + private const string SignaturePrefix = "sha256="; + + /// + public Result Parse(GitWebhookDelivery delivery, string secret) + { + ArgumentNullException.ThrowIfNull(delivery); + + if (!VerifySignature(delivery, secret)) + { + return Result.Failure(GitErrors.WebhookSignatureInvalid()); + } + + if (!TryGetHeader(delivery, DeliveryHeader, out var deliveryId) || string.IsNullOrWhiteSpace(deliveryId)) + { + return Result.Failure(Error.Validation( + $"{GitErrors.Prefix}.MalformedDelivery", $"The delivery is missing the {DeliveryHeader} header.")); + } + + if (!TryGetHeader(delivery, EventHeader, out var eventType) || string.IsNullOrWhiteSpace(eventType)) + { + return Result.Failure(Error.Validation( + $"{GitErrors.Prefix}.MalformedDelivery", $"The delivery is missing the {EventHeader} header.")); + } + + JsonElement root; + JsonDocument document; + try + { + document = JsonDocument.Parse(delivery.Body); + root = document.RootElement; + } + catch (JsonException ex) + { + return Result.Failure(Error.Validation( + $"{GitErrors.Prefix}.MalformedDelivery", $"The delivery body is not valid JSON: {ex.Message}")); + } + + using (document) + { + var repository = ParseRepository(root); + var parsed = Translate(eventType, root, deliveryId, repository); + return Result.Success(parsed); + } + } + + private static GitWebhookEvent Translate( + string eventType, JsonElement root, string deliveryId, GitRepositoryRef? repository) => eventType switch + { + "push" => TranslatePush(root, deliveryId, repository), + "pull_request" => TranslatePullRequest(root, deliveryId, repository), + "workflow_run" => TranslateWorkflowRun(root, deliveryId, repository), + "installation" or "installation_repositories" => TranslateInstallation(eventType, root, deliveryId), + _ => new GitWebhookEvent.Unsupported(eventType) { DeliveryId = deliveryId, Repository = repository }, + }; + + private static GitWebhookEvent TranslatePush(JsonElement root, string deliveryId, GitRepositoryRef? repository) + { + var reference = GetString(root, "ref") ?? string.Empty; + var sha = GetString(root, "after") ?? GetString(GetProperty(root, "head_commit"), "id") ?? string.Empty; + + if (reference.StartsWith("refs/tags/", StringComparison.Ordinal)) + { + return new GitWebhookEvent.TagPushed(reference["refs/tags/".Length..], sha) + { + DeliveryId = deliveryId, + Repository = repository, + }; + } + + return new GitWebhookEvent.Push(reference, sha) { DeliveryId = deliveryId, Repository = repository }; + } + + private static GitWebhookEvent TranslatePullRequest(JsonElement root, string deliveryId, GitRepositoryRef? repository) + { + var pr = GetProperty(root, "pull_request"); + var action = GetString(root, "action") ?? string.Empty; + var number = GetInt(root, "number") ?? GetInt(pr, "number") ?? 0; + var source = GetString(GetProperty(pr, "head"), "ref") ?? string.Empty; + var targetRef = GetString(GetProperty(pr, "base"), "ref") ?? string.Empty; + + return new GitWebhookEvent.PullRequestChanged(action, number, source, targetRef) + { + DeliveryId = deliveryId, + Repository = repository, + }; + } + + private static GitWebhookEvent TranslateWorkflowRun(JsonElement root, string deliveryId, GitRepositoryRef? repository) + { + var action = GetString(root, "action"); + var run = GetProperty(root, "workflow_run"); + + if (!string.Equals(action, "completed", StringComparison.Ordinal)) + { + return new GitWebhookEvent.Unsupported("workflow_run") { DeliveryId = deliveryId, Repository = repository }; + } + + var runId = GetLong(run, "id")?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + var pipeline = GetString(run, "name") ?? string.Empty; + var status = GitHubPipelineStatusMapper.Map(GetString(run, "status"), GetString(run, "conclusion")); + var reference = GetString(run, "head_branch") ?? string.Empty; + + return new GitWebhookEvent.PipelineRunCompleted(runId, pipeline, status, reference) + { + DeliveryId = deliveryId, + Repository = repository, + }; + } + + private static GitWebhookEvent TranslateInstallation(string eventType, JsonElement root, string deliveryId) + { + var installation = GetProperty(root, "installation"); + var account = GetProperty(installation, "account"); + var installationId = GetLong(installation, "id")?.ToString(System.Globalization.CultureInfo.InvariantCulture) + ?? string.Empty; + var login = GetString(account, "login") ?? string.Empty; + var accountType = string.Equals(GetString(account, "type"), "User", StringComparison.OrdinalIgnoreCase) + ? GitAccountType.User + : GitAccountType.Organization; + var action = GetString(root, "action"); + + var change = eventType == "installation_repositories" + ? GitConnectionChangeKind.RepositoriesChanged + : action switch + { + "created" => GitConnectionChangeKind.Installed, + "deleted" => GitConnectionChangeKind.Uninstalled, + "suspend" => GitConnectionChangeKind.Suspended, + "unsuspend" => GitConnectionChangeKind.Unsuspended, + _ => GitConnectionChangeKind.RepositoriesChanged, + }; + + return new GitWebhookEvent.ConnectionChanged(login, accountType, installationId, change) + { + DeliveryId = deliveryId, + }; + } + + private static GitRepositoryRef? ParseRepository(JsonElement root) + { + var fullName = GetString(GetProperty(root, "repository"), "full_name"); + return fullName?.Split('/') is [var ns, var name] ? new GitRepositoryRef(ns, name) : null; + } + + private static bool VerifySignature(GitWebhookDelivery delivery, string secret) + { + if (string.IsNullOrEmpty(secret)) + { + return false; + } + + if (!TryGetHeader(delivery, SignatureHeader, out var header) + || string.IsNullOrWhiteSpace(header) + || !header.StartsWith(SignaturePrefix, StringComparison.Ordinal)) + { + return false; + } + + byte[] provided; + try + { + provided = Convert.FromHexString(header[SignaturePrefix.Length..]); + } + catch (FormatException) + { + return false; + } + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(delivery.Body)); + return provided.Length == expected.Length && CryptographicOperations.FixedTimeEquals(provided, expected); + } + + private static bool TryGetHeader(GitWebhookDelivery delivery, string name, out string value) + { + if (delivery.Headers.TryGetValue(name, out var direct)) + { + value = direct; + return true; + } + + foreach (var (key, headerValue) in delivery.Headers) + { + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) + { + value = headerValue; + return true; + } + } + + value = string.Empty; + return false; + } + + private static JsonElement GetProperty(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) + ? value + : default; + + private static string? GetString(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static int? GetInt(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out var number) + ? number + : null; + + private static long? GetLong(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var number) + ? number + : null; +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/Compendium.Adapters.GitHub.Tests.csproj b/tests/Unit/Compendium.Adapters.GitHub.Tests/Compendium.Adapters.GitHub.Tests.csproj new file mode 100644 index 0000000..670863e --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/Compendium.Adapters.GitHub.Tests.csproj @@ -0,0 +1,43 @@ + + + + Compendium.Adapters.GitHub.Tests + Compendium.Adapters.GitHub.Tests + false + true + enable + enable + false + false + + direct + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAccessControlServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAccessControlServiceTests.cs new file mode 100644 index 0000000..9f04138 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAccessControlServiceTests.cs @@ -0,0 +1,109 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubAccessControlServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + [Fact] + public async Task EnsureTeam_CreatesTheTeamWhenAbsent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/teams/engineering").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/teams").UsingPost()) + .RespondWith(Json.Created(TeamJson())); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.EnsureTeamAsync(harness.AppConnection(), "acme", new EnsureGitTeam { Name = "Engineering" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Slug.Should().Be("engineering"); + } + + [Fact] + public async Task EnsureTeam_ReturnsTheExistingTeam() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/teams/engineering").UsingGet()) + .RespondWith(Json.Ok(TeamJson())); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.EnsureTeamAsync(harness.AppConnection(), "acme", new EnsureGitTeam { Name = "Engineering" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Name.Should().Be("Engineering"); + } + + [Fact] + public async Task AddTeamMember_ResolvesTheTeamAndSetsMembership() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/teams/eng").UsingGet()) + .RespondWith(Json.Ok(TeamJson(slug: "eng"))); + harness.Server.Given(Request.Create().WithPath("/api/v3/teams/5/memberships/bob").UsingPut()) + .RespondWith(Json.Ok(new { url = "https://x", role = "member", state = "active" })); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.AddTeamMemberAsync(harness.AppConnection(), "acme", "eng", "bob", GitTeamRole.Member); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task SetTeamRepositoryRole_GrantsTheRole() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/orgs/acme/teams/eng/repos/acme/billing").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.SetTeamRepositoryRoleAsync( + harness.AppConnection(), "acme", "eng", Repo, GitRepositoryRole.Write); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task SetUserRepositoryRole_AddsTheCollaborator() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/collaborators/bob").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.SetUserRepositoryRoleAsync(harness.AppConnection(), Repo, "bob", GitRepositoryRole.Admin); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task RemoveUser_IsIdempotentOnNotFound() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/collaborators/bob").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.RemoveUserFromRepositoryAsync(harness.AppConnection(), Repo, "bob")).IsSuccess.Should().BeTrue(); + } + + private static object TeamJson(string slug = "engineering") => new + { + id = 5, + node_id = "T_1", + slug, + name = "Engineering", + }; +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAppTokenServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAppTokenServiceTests.cs new file mode 100644 index 0000000..2eb10a3 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubAppTokenServiceTests.cs @@ -0,0 +1,213 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubAppTokenServiceTests +{ + private const string TokenPath = "/app/installations/555/access_tokens"; + + [Fact] + public void CreateAppJwt_SignsAnRs256JwtBoundToTheApp() + { + using var harness = new GitHubTestHarness(); + + var jwt = harness.TokenService.CreateAppJwt(harness.Options.DefaultApp); + + jwt.IsSuccess.Should().BeTrue(); + var parts = jwt.Value.Split('.'); + parts.Should().HaveCount(3); + + var header = JsonSerializer.Deserialize(Base64UrlDecode(parts[0])); + header.GetProperty("alg").GetString().Should().Be("RS256"); + + var payload = JsonSerializer.Deserialize(Base64UrlDecode(parts[1])); + payload.GetProperty("iss").GetString().Should().Be("1001"); + var exp = DateTimeOffset.FromUnixTimeSeconds(payload.GetProperty("exp").GetInt64()); + exp.Should().BeAfter(DateTimeOffset.UtcNow); + exp.Should().BeBefore(DateTimeOffset.UtcNow.AddMinutes(10)); + + // The signature verifies against the app's key. + using var rsa = RSA.Create(); + rsa.ImportFromPem(harness.Options.DefaultApp.PrivateKeyPem); + var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + rsa.VerifyData(signingInput, Base64UrlDecode(parts[2]), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + .Should().BeTrue(); + } + + [Fact] + public void CreateAppJwt_FailsWhenAppIdMissing() + { + using var harness = new GitHubTestHarness(); + var app = new GitHubAppRegistration { PrivateKeyPem = harness.Options.DefaultApp.PrivateKeyPem }; + + harness.TokenService.CreateAppJwt(app).Error.Code.Should().Be("GitHubApp.AppIdMissing"); + } + + [Fact] + public void CreateAppJwt_FailsWhenPrivateKeyMissing() + { + using var harness = new GitHubTestHarness(); + harness.TokenService.CreateAppJwt(new GitHubAppRegistration { AppId = "5" }).Error.Code + .Should().Be("GitHubApp.PrivateKeyMissing"); + } + + [Fact] + public void CreateAppJwt_FailsOnAMalformedPrivateKey() + { + using var harness = new GitHubTestHarness(); + var app = new GitHubAppRegistration { AppId = "5", PrivateKeyPem = "-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----" }; + + harness.TokenService.CreateAppJwt(app).Error.Code.Should().Be("GitHubApp.JwtSigningFailed"); + } + + [Fact] + public async Task GetInstallationToken_ReturnsAUsableToken() + { + using var harness = new GitHubTestHarness(); + StubMint(harness, "ghs_installation"); + + var result = await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, appKey: null, harness.BaseUri, "555", scope: null, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Token.Should().Be("ghs_installation"); + result.Value.HttpBasicUsername.Should().Be("x-access-token"); + result.Value.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); + } + + [Fact] + public async Task GetInstallationToken_ServesTheCacheOnASecondCall() + { + using var harness = new GitHubTestHarness(); + StubMint(harness, "ghs_cached"); + + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + + harness.Server.LogEntries.Count().Should().Be(1, "the second call should hit the in-memory cache"); + } + + [Fact] + public async Task GetInstallationToken_InvalidateForcesAReMint() + { + using var harness = new GitHubTestHarness(); + StubMint(harness, "ghs_x"); + + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + harness.TokenService.Invalidate(null, "555"); + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + + harness.Server.LogEntries.Count().Should().Be(2); + } + + [Fact] + public async Task GetInstallationToken_RetriesOnceOnUnauthorized() + { + using var harness = new GitHubTestHarness(); + harness.Server + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .InScenario("mint").WillSetStateTo("retried") + .RespondWith(Response.Create().WithStatusCode(401)); + harness.Server + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .InScenario("mint").WhenStateIs("retried") + .RespondWith(TokenResponse("ghs_after_retry")); + + var result = await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Token.Should().Be("ghs_after_retry"); + harness.Server.LogEntries.Count().Should().Be(2); + } + + [Fact] + public async Task GetInstallationToken_ScopedMint_BypassesTheCacheAndSendsRepositories() + { + using var harness = new GitHubTestHarness(); + StubMint(harness, "ghs_scoped"); + var scope = new GitAccessTokenScope + { + Repositories = [new GitRepositoryRef("acme", "billing")], + Permissions = new Dictionary { ["contents"] = "read" }, + }; + + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", scope, CancellationToken.None); + await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", scope, CancellationToken.None); + + harness.Server.LogEntries.Count().Should().Be(2, "scoped mints must not be cached"); + var body = harness.Server.LogEntries.First().RequestMessage.Body ?? string.Empty; + body.Should().Contain("billing").And.Contain("contents"); + } + + [Fact] + public async Task GetInstallationToken_RejectsAnEmptyInstallationId() + { + using var harness = new GitHubTestHarness(); + + var result = await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, " ", null, CancellationToken.None); + + result.Error.Code.Should().Be("GitHubApp.InstallationIdRequired"); + } + + [Fact] + public async Task GetInstallationToken_FailsOnAnEmptyTokenPayload() + { + using var harness = new GitHubTestHarness(); + harness.Server + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201).WithBodyAsJson(new { token = "", expires_at = DateTimeOffset.UtcNow })); + + var result = await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + + result.Error.Code.Should().Be("GitHubApp.InstallationTokenMalformed"); + } + + [Fact] + public async Task GetInstallationToken_MapsANonAuthFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await harness.TokenService.GetInstallationTokenAsync( + harness.Options.DefaultApp, null, harness.BaseUri, "555", null, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + } + + private static void StubMint(GitHubTestHarness harness, string token) => + harness.Server.Given(Request.Create().WithPath(TokenPath).UsingPost()).RespondWith(TokenResponse(token)); + + private static IResponseBuilder TokenResponse(string token) => + Response.Create().WithStatusCode(201).WithBodyAsJson(new { token, expires_at = DateTimeOffset.UtcNow.AddHours(1) }); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded = (padded.Length % 4) switch { 2 => padded + "==", 3 => padded + "=", _ => padded }; + return Convert.FromBase64String(padded); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubBranchPolicyServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubBranchPolicyServiceTests.cs new file mode 100644 index 0000000..968fc2a --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubBranchPolicyServiceTests.cs @@ -0,0 +1,88 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubBranchPolicyServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + private GitHubBranchPolicyService Service(GitHubTestHarness harness) => new(harness.Broker, harness.RestExecutor); + + [Fact] + public async Task Apply_CreatesARulesetWhenNoneMatches() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingGet()) + .RespondWith(Json.Ok(Array.Empty())); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingPost()) + .RespondWith(Json.Created(new { id = 7, name = "compendium:main" })); + + var result = await Service(harness).ApplyAsync(harness.PatConnection(), Repo, new GitBranchPolicyRequest + { + Pattern = "main", + RequiredApprovals = 2, + RequiredStatusChecks = ["build"], + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("7"); + result.Value.Pattern.Should().Be("main"); + } + + [Fact] + public async Task Apply_UpdatesTheExistingRulesetForThePattern() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingGet()) + .RespondWith(Json.Ok(new[] { new { id = 7, name = "compendium:main" } })); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets/7").UsingPut()) + .RespondWith(Json.Ok(new { id = 7, name = "compendium:main" })); + + var result = await Service(harness).ApplyAsync(harness.PatConnection(), Repo, new GitBranchPolicyRequest + { + Pattern = "main", + EnforceForAdmins = true, + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("7"); + } + + [Fact] + public async Task Remove_IsIdempotent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets/7").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + + (await Service(harness).RemoveAsync(harness.PatConnection(), Repo, "7")).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task List_RecoversThePatternFromTheRulesetName() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingGet()) + .RespondWith(Json.Ok(new[] + { + new { id = 7, name = "compendium:release/*" }, + new { id = 8, name = "hand-authored" }, + })); + + var result = await Service(harness).ListAsync(harness.PatConnection(), Repo); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().Contain(p => p.Pattern == "release/*"); + result.Value.Should().Contain(p => p.Pattern == "hand-authored"); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCapabilitiesAndProvisionerTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCapabilitiesAndProvisionerTests.cs new file mode 100644 index 0000000..460e611 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCapabilitiesAndProvisionerTests.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Services; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubCapabilitiesAndProvisionerTests +{ + [Fact] + public void Capabilities_DeclareGitHubAsTheProvider() + { + GitHubCapabilities.Matrix.Provider.Should().Be("github"); + } + + [Fact] + public void Capabilities_DeclareEveryCapability() + { + foreach (var capability in Enum.GetValues()) + { + GitHubCapabilities.Matrix.Entries.Should().ContainKey(capability); + } + } + + [Fact] + public void Capabilities_PipelineTriggerIsPartial_WithALimitation() + { + var support = GitHubCapabilities.Matrix.Entries[GitCapability.PipelineTrigger]; + support.Level.Should().Be(GitCapabilityLevel.Partial); + support.Limitation.Should().Contain("workflow_dispatch"); + } + + [Fact] + public void Capabilities_NamespaceProvisioningIsNone_AndNotSupported() + { + GitHubCapabilities.Matrix.Supports(GitCapability.NamespaceProvisioning).Should().BeFalse(); + GitHubCapabilities.Matrix.EnsureSupported(GitCapability.NamespaceProvisioning).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Capabilities_RepositoryManagementIsSupported() + { + GitHubCapabilities.Matrix.EnsureSupported(GitCapability.RepositoryManagement).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task NamespaceProvisioner_AlwaysFailsWithCapabilityNotSupported() + { + var provisioner = new GitHubNamespaceProvisioner(); + var connection = new GitConnection + { + Provider = "github", + Credential = new GitCredential.PersonalAccessToken("x"), + }; + + var result = await provisioner.CreateNamespaceAsync(connection, new CreateGitNamespace { Name = "acme" }); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Git.CapabilityNotSupported"); + } + + [Fact] + public void ResolveApp_ReturnsDefaultForNullKey_AndLooksUpNamedApps() + { + var options = new GitHubAdapterOptions(); + options.Apps["secondary"] = new GitHubAppRegistration { AppId = "222" }; + + options.ResolveApp(null).Should().BeSameAs(options.DefaultApp); + options.ResolveApp("secondary")!.AppId.Should().Be("222"); + options.ResolveApp("missing").Should().BeNull(); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCiConfigurationServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCiConfigurationServiceTests.cs new file mode 100644 index 0000000..54f78ec --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCiConfigurationServiceTests.cs @@ -0,0 +1,140 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubCiConfigurationServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + private readonly string _publicKey = Convert.ToBase64String(Sodium.PublicKeyBox.GenerateKeyPair().PublicKey); + + private GitHubCiConfigurationService Service(GitHubTestHarness harness) => + new(harness.Broker, harness.RestExecutor, harness.Sealer); + + [Fact] + public async Task SetSecrets_RepositoryScope_SealsAndUploadsEach() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/public-key").UsingGet()) + .RespondWith(Json.Ok(new { key_id = "kid", key = _publicKey })); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/TOKEN").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + + var result = await Service(harness).SetSecretsAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["TOKEN"] = "value" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task SetSecrets_NamespaceScope_UsesOrgVisibility() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/orgs/acme/actions/secrets/public-key").UsingGet()) + .RespondWith(Json.Ok(new { key_id = "kid", key = _publicKey })); + harness.Server.Given(Request.Create().WithPath("/orgs/acme/actions/secrets/TOKEN").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + + var result = await Service(harness).SetSecretsAsync( + harness.PatConnection(), new GitConfigurationScope.Namespace("acme"), + new Dictionary { ["TOKEN"] = "value" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + var body = harness.Server.LogEntries.Last(e => e.RequestMessage.Method == "PUT").RequestMessage.Body ?? string.Empty; + body.Should().Contain("visibility"); + } + + [Fact] + public async Task SetSecrets_EnvironmentScope_ResolvesTheRepositoryId() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing").UsingGet()) + .RespondWith(Json.Ok(new { id = 4242 })); + harness.Server.Given(Request.Create().WithPath("/repositories/4242/environments/prod/secrets/public-key").UsingGet()) + .RespondWith(Json.Ok(new { key_id = "kid", key = _publicKey })); + harness.Server.Given(Request.Create().WithPath("/repositories/4242/environments/prod/secrets/TOKEN").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + + var result = await Service(harness).SetSecretsAsync( + harness.PatConnection(), new GitConfigurationScope.Environment(Repo, "prod"), + new Dictionary { ["TOKEN"] = "value" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task SetSecrets_PropagatesAPublicKeyFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/public-key").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await Service(harness).SetSecretsAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["TOKEN"] = "value" }); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task DeleteSecret_IsIdempotent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/GONE").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + + (await Service(harness).DeleteSecretAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), "GONE")).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task SetVariables_CreatesANewVariable() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201)); + + var result = await Service(harness).SetVariablesAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["REGION"] = "eu" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task SetVariables_UpdatesOnConflict() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(409).WithBody("variable already exists")); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables/REGION").UsingPatch()) + .RespondWith(Response.Create().WithStatusCode(204)); + + var result = await Service(harness).SetVariablesAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["REGION"] = "eu" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task DeleteVariable_IsIdempotent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables/GONE").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + + (await Service(harness).DeleteVariableAsync( + harness.PatConnection(), new GitConfigurationScope.Repository(Repo), "GONE")).IsSuccess.Should().BeTrue(); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubClientProviderTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubClientProviderTests.cs new file mode 100644 index 0000000..6fa9cf9 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubClientProviderTests.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Http; +using Compendium.Adapters.GitHub.Tests.Infrastructure; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubClientProviderTests +{ + [Fact] + public async Task GetClient_MintsATokenAndReturnsAClient() + { + using var harness = new GitHubTestHarness(); + var provider = new GitHubClientProvider(harness.Broker); + + var result = await provider.GetClientAsync(harness.PatConnection(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task GetClient_CachesTheClientPerCredentialIdentity() + { + using var harness = new GitHubTestHarness(); + var provider = new GitHubClientProvider(harness.Broker); + var connection = harness.PatConnection("same-token"); + + var first = await provider.GetClientAsync(connection, CancellationToken.None); + var second = await provider.GetClientAsync(connection, CancellationToken.None); + + ReferenceEquals(first.Value, second.Value).Should().BeTrue("clients are cached per credential and host"); + } + + [Fact] + public async Task GetClient_PropagatesAMintFailure() + { + using var harness = new GitHubTestHarness(); + var provider = new GitHubClientProvider(harness.Broker); + var connection = harness.AppConnection() with { Credential = new GitCredential.AppInstallation("1", "missing") }; + + (await provider.GetClientAsync(connection, CancellationToken.None)).Error.Code.Should().Be("Git.NotConfigured"); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCredentialBrokerTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCredentialBrokerTests.cs new file mode 100644 index 0000000..c7049d4 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubCredentialBrokerTests.cs @@ -0,0 +1,234 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubCredentialBrokerTests +{ + [Fact] + public async Task Mint_PassesThroughAPersonalAccessToken_WithAFarFutureExpiry() + { + using var harness = new GitHubTestHarness(); + + var result = await harness.Broker.MintAsync(harness.PatConnection("pat_abc")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Token.Should().Be("pat_abc"); + result.Value.HttpBasicUsername.Should().Be("x-access-token"); + result.Value.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddYears(5)); + } + + [Fact] + public async Task Mint_PassesThroughAServiceAccountToken() + { + using var harness = new GitHubTestHarness(); + var connection = harness.PatConnection() with { Credential = new GitCredential.ServiceAccountToken("bot_tok") }; + + (await harness.Broker.MintAsync(connection)).Value.Token.Should().Be("bot_tok"); + } + + [Fact] + public async Task Mint_OAuthToken_ReportsAnEightHourExpiry() + { + using var harness = new GitHubTestHarness(); + var connection = harness.PatConnection() with { Credential = new GitCredential.OAuthAccessToken("oauth_tok") }; + + var result = await harness.Broker.MintAsync(connection); + + result.Value.ExpiresAt.Should().BeBefore(DateTimeOffset.UtcNow.AddHours(9)); + result.Value.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow.AddHours(7)); + } + + [Fact] + public async Task Mint_AppInstallation_MintsViaTheTokenService() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/app/installations/555/access_tokens").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201) + .WithBodyAsJson(new { token = "ghs_from_app", expires_at = DateTimeOffset.UtcNow.AddHours(1) })); + + (await harness.Broker.MintAsync(harness.AppConnection())).Value.Token.Should().Be("ghs_from_app"); + } + + [Fact] + public async Task Mint_AppInstallation_UnknownAppKey_FailsNotConfigured() + { + using var harness = new GitHubTestHarness(); + var connection = harness.AppConnection() with { Credential = new GitCredential.AppInstallation("555", "missing") }; + + (await harness.Broker.MintAsync(connection)).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task Validate_AppInstallation_ReportsTheInstalledAccount() + { + 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", name = "Acme Inc" }, + })); + + var result = await harness.Broker.ValidateAsync(harness.AppConnection()); + + result.IsSuccess.Should().BeTrue(); + result.Value.AccountLogin.Should().Be("acme"); + result.Value.AccountType.Should().Be(GitAccountType.Organization); + result.Value.DisplayName.Should().Be("Acme Inc"); + } + + [Fact] + public async Task Validate_TokenCredential_ReadsTheAuthenticatedUser() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/user").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBodyAsJson(new { login = "octocat", type = "User" })); + + var result = await harness.Broker.ValidateAsync(harness.PatConnection()); + + result.Value.AccountLogin.Should().Be("octocat"); + result.Value.AccountType.Should().Be(GitAccountType.User); + } + + [Fact] + public async Task Validate_TokenCredential_PropagatesAnAuthFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/user").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(401)); + + (await harness.Broker.ValidateAsync(harness.PatConnection())).Error.Code.Should().Be("Git.AuthenticationFailed"); + } + + [Fact] + public async Task ResolveAppInstallation_FindsAnOrganizationInstallation() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/orgs/acme/installation").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBodyAsJson(new { id = 999, account = new { login = "acme", type = "Organization" } })); + + var result = await harness.Broker.ResolveAppInstallationAsync("acme"); + + result.IsSuccess.Should().BeTrue(); + result.Value.InstallationId.Should().Be("999"); + result.Value.AccountLogin.Should().Be("acme"); + } + + [Fact] + public async Task ResolveAppInstallation_FallsBackToTheUserInstallation() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/orgs/octocat/installation").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + harness.Server.Given(Request.Create().WithPath("/users/octocat/installation").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBodyAsJson(new { id = 42, account = new { login = "octocat", type = "User" } })); + + var result = await harness.Broker.ResolveAppInstallationAsync("octocat"); + + result.Value.InstallationId.Should().Be("42"); + result.Value.AccountType.Should().Be(GitAccountType.User); + } + + [Fact] + public async Task ResolveAppInstallation_NotInstalled_ReturnsAnInstallUrl() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/orgs/ghost/installation").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + harness.Server.Given(Request.Create().WithPath("/users/ghost/installation").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await harness.Broker.ResolveAppInstallationAsync("ghost"); + + result.Error.Code.Should().Be("Git.AppNotInstalled"); + result.Error.Metadata["installUrl"].Should().Be("https://github.com/apps/compendium-app/installations/new"); + } + + [Fact] + public async Task ListAppInstallations_PagesThroughEveryInstallation() + { + using var harness = new GitHubTestHarness(); + var firstPage = Enumerable.Range(1, 100) + .Select(i => new { id = i, account = new { login = $"org{i}", type = "Organization" } }).ToArray(); + harness.Server.Given(Request.Create().WithPath("/app/installations").WithParam("page", "1").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(firstPage)); + harness.Server.Given(Request.Create().WithPath("/app/installations").WithParam("page", "2").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBodyAsJson(new[] { new { id = 101, account = new { login = "last", type = "Organization" } } })); + + var result = await harness.Broker.ListAppInstallationsAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(101); + result.Value[^1].AccountLogin.Should().Be("last"); + } + + [Fact] + public async Task Validate_AppInstallation_UnknownAppKey_FailsNotConfigured() + { + using var harness = new GitHubTestHarness(); + var connection = harness.AppConnection() with { Credential = new GitCredential.AppInstallation("1", "missing") }; + + (await harness.Broker.ValidateAsync(connection)).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task ResolveAppInstallation_UnknownAppKey_FailsNotConfigured() + { + using var harness = new GitHubTestHarness(); + + (await harness.Broker.ResolveAppInstallationAsync("acme", "missing")).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task ListAppInstallations_UnknownAppKey_FailsNotConfigured() + { + using var harness = new GitHubTestHarness(); + + (await harness.Broker.ListAppInstallationsAsync("missing")).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task ListAppInstallations_PropagatesAPageFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/app/installations").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(500)); + + (await harness.Broker.ListAppInstallationsAsync()).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task Authorize_PropagatesAMintFailure() + { + using var harness = new GitHubTestHarness(); + var connection = harness.AppConnection() with { Credential = new GitCredential.AppInstallation("1", "missing") }; + + (await harness.Broker.AuthorizeAsync(connection)).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task Authorize_PairsAMintedTokenWithTheApiBase() + { + using var harness = new GitHubTestHarness(); + + var result = await harness.Broker.AuthorizeAsync(harness.PatConnection("pat_xyz")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Token.Should().Be("pat_xyz"); + result.Value.ApiBase.Should().Be(GitHubDefaults.EnsureTrailingSlash(harness.BaseUri)); + result.Value.ToString().Should().NotContain("pat_xyz"); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubEnvironmentServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubEnvironmentServiceTests.cs new file mode 100644 index 0000000..66c24d6 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubEnvironmentServiceTests.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubEnvironmentServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + private GitHubEnvironmentService Service(GitHubTestHarness harness) => new(harness.Broker, harness.RestExecutor); + + [Fact] + public async Task Ensure_CreatesOrUpdatesTheEnvironment() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/environments/production").UsingPut()) + .RespondWith(Json.Ok(new { name = "production", html_url = "https://github.com/acme/billing/deployments/activity_log?environments_filter=production" })); + + var result = await Service(harness).EnsureAsync(harness.PatConnection(), Repo, new EnsureGitEnvironment { Name = "production" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Name.Should().Be("production"); + result.Value.HtmlUrl.Should().NotBeNull(); + } + + [Fact] + public async Task Delete_IsIdempotent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/environments/gone").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + + (await Service(harness).DeleteAsync(harness.PatConnection(), Repo, "gone")).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task List_MapsEnvironments() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/environments").UsingGet()) + .RespondWith(Json.Ok(new + { + total_count = 1, + environments = new[] { new { name = "production", html_url = "https://x" } }, + })); + + var result = await Service(harness).ListAsync(harness.PatConnection(), Repo); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(e => e.Name == "production"); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubErrorMapperTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubErrorMapperTests.cs new file mode 100644 index 0000000..6704e0a --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubErrorMapperTests.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Net; +using Compendium.Adapters.GitHub.Http; +using NSubstitute; +using Octokit; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubErrorMapperTests +{ + [Fact] + public void FromStatus_409_MapsToConflict() + { + GitHubErrorMapper.FromStatus(409, new GitRestErrorContext { ConflictResource = "acme/x" }, "exists") + .Code.Should().Be("Git.Conflict"); + } + + [Fact] + public void FromStatus_422WithoutConflictWording_MapsToProviderRejected() + { + GitHubErrorMapper.FromStatus(422, GitRestErrorContext.None, "some other validation error") + .Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public void FromStatus_403WithoutRateLimit_MapsToProviderRejected() + { + GitHubErrorMapper.FromStatus(403, GitRestErrorContext.None, "forbidden") + .Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public void FromException_NotFound_UsesRepositoryContext() + { + var ex = new NotFoundException(FakeResponse(HttpStatusCode.NotFound)); + + GitHubErrorMapper.FromException(ex, GitRestErrorContext.ForRepository(new GitRepositoryRef("a", "b"))) + .Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public void FromException_NotFound_UsesNamespaceContext() + { + var ex = new NotFoundException(FakeResponse(HttpStatusCode.NotFound)); + + GitHubErrorMapper.FromException(ex, GitRestErrorContext.ForNamespace("acme")) + .Code.Should().Be("Git.NamespaceNotFound"); + } + + [Fact] + public void FromException_NotFound_WithoutContext_MapsToProviderRejected() + { + var ex = new NotFoundException(FakeResponse(HttpStatusCode.NotFound)); + + GitHubErrorMapper.FromException(ex, GitRestErrorContext.None).Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public void FromException_Authorization_MapsToAuthenticationFailed() + { + var ex = new AuthorizationException(FakeResponse(HttpStatusCode.Unauthorized)); + + GitHubErrorMapper.FromException(ex, GitRestErrorContext.None).Code.Should().Be("Git.AuthenticationFailed"); + } + + [Fact] + public void FromException_ValidationAlreadyExists_MapsToConflict() + { + var ex = new ApiValidationException( + FakeResponse(HttpStatusCode.UnprocessableEntity, "{\"message\":\"Reference already exists\"}")); + + GitHubErrorMapper.FromException(ex, new GitRestErrorContext { ConflictResource = "acme/x" }) + .Code.Should().Be("Git.Conflict"); + } + + [Fact] + public void FromException_GenericApiException_MapsToProviderRejected() + { + var ex = new ApiException(FakeResponse(HttpStatusCode.InternalServerError)); + + var error = GitHubErrorMapper.FromException(ex, GitRestErrorContext.None); + error.Code.Should().Be("Git.ProviderRejected"); + error.Metadata["statusCode"].Should().Be(500); + } + + [Fact] + public void FromException_UnknownException_MapsToProviderRejected() + { + GitHubErrorMapper.FromException(new InvalidOperationException("boom"), GitRestErrorContext.None) + .Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public void FromException_RateLimit_MapsToThrottledWithReset() + { + var reset = DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds(); + var apiInfo = new ApiInfo( + new Dictionary(), [], [], "etag", new RateLimit(5000, 0, reset), TimeSpan.Zero); + var response = FakeResponse(HttpStatusCode.Forbidden); + response.ApiInfo.Returns(apiInfo); + + var error = GitHubErrorMapper.FromException(new RateLimitExceededException(response), GitRestErrorContext.None); + + error.Code.Should().Be("Git.Throttled"); + error.Metadata.Should().ContainKey("retryAfterSeconds"); + } + + [Fact] + public void FromException_Abuse_MapsToThrottled() + { + var response = FakeResponse( + HttpStatusCode.Forbidden, headers: new Dictionary { ["Retry-After"] = "30" }); + + GitHubErrorMapper.FromException(new AbuseException(response), GitRestErrorContext.None) + .Code.Should().Be("Git.Throttled"); + } + + [Fact] + public void FromException_SecondaryRateLimit_MapsToThrottled() + { + GitHubErrorMapper.FromException( + new SecondaryRateLimitExceededException(FakeResponse(HttpStatusCode.Forbidden)), GitRestErrorContext.None) + .Code.Should().Be("Git.Throttled"); + } + + [Fact] + public void FromException_ValidationWithErrorDetails_MapsToConflict() + { + const string body = """ + {"message":"Validation Failed","errors":[{"resource":"Repository","field":"name","code":"custom","message":"name already exists"}]} + """; + + var error = GitHubErrorMapper.FromException( + new ApiValidationException(FakeResponse(HttpStatusCode.UnprocessableEntity, body)), + new GitRestErrorContext { ConflictResource = "acme/x" }); + + error.Code.Should().Be("Git.Conflict"); + } + + private static IResponse FakeResponse( + HttpStatusCode statusCode, string body = "", IDictionary? headers = null) + { + var response = Substitute.For(); + response.StatusCode.Returns(statusCode); + response.Body.Returns(body); + response.ContentType.Returns("application/json"); + response.Headers.Returns((IReadOnlyDictionary)(headers ?? new Dictionary())); + return response; + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineServiceTests.cs new file mode 100644 index 0000000..bf77295 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineServiceTests.cs @@ -0,0 +1,108 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubPipelineServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + [Fact] + public async Task Trigger_ReturnsAHandleWithNoRunId() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create() + .WithPath("/api/v3/repos/acme/billing/actions/workflows/bootstrap.yml/dispatches").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + var service = new GitHubPipelineService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.TriggerAsync(harness.AppConnection(), Repo, new TriggerGitPipeline + { + Pipeline = "bootstrap.yml", + Reference = "main", + Inputs = new Dictionary { ["version"] = "1.2.3" }, + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.RunId.Should().BeNull(); + } + + [Fact] + public async Task GetRun_MapsStatusAndConclusion() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/actions/runs/999").UsingGet()) + .RespondWith(Json.Ok(RunJson(999, "completed", "success"))); + var service = new GitHubPipelineService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.GetRunAsync(harness.AppConnection(), Repo, "999"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("999"); + result.Value.Status.Should().Be(GitPipelineStatus.Succeeded); + result.Value.Reference.Should().Be("main"); + } + + [Fact] + public async Task GetRun_RejectsANonNumericRunId() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubPipelineService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.GetRunAsync(harness.AppConnection(), Repo, "not-a-number")).Error.Code + .Should().Be("GitHub.InvalidRunId"); + } + + [Fact] + public async Task ListRuns_AllWorkflows_MapsAndFiltersByReference() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/actions/runs").UsingGet()) + .RespondWith(Json.Ok(new + { + total_count = 2, + workflow_runs = new[] { RunJson(1, "completed", "success", "main"), RunJson(2, "in_progress", null, "dev") }, + })); + var service = new GitHubPipelineService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListRunsAsync(harness.AppConnection(), Repo, new ListGitPipelineRuns { Reference = "main" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(r => r.Id == "1"); + } + + [Fact] + public async Task ListRuns_ByWorkflow_UsesTheWorkflowScopedEndpoint() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create() + .WithPath("/api/v3/repos/acme/billing/actions/workflows/ci.yml/runs").UsingGet()) + .RespondWith(Json.Ok(new { total_count = 1, workflow_runs = new[] { RunJson(3, "queued", null) } })); + var service = new GitHubPipelineService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListRunsAsync(harness.AppConnection(), Repo, new ListGitPipelineRuns { Pipeline = "ci.yml" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(r => r.Id == "3" && r.Status == GitPipelineStatus.Queued); + } + + private static object RunJson(long id, string status, string? conclusion, string headBranch = "main") => new + { + id, + name = "CI", + status, + conclusion, + head_branch = headBranch, + html_url = $"https://github.com/acme/billing/actions/runs/{id}", + created_at = "2026-07-24T00:00:00Z", + }; +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineStatusMapperTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineStatusMapperTests.cs new file mode 100644 index 0000000..279aa8c --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubPipelineStatusMapperTests.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubPipelineStatusMapperTests +{ + [Theory] + [InlineData("completed", "success", GitPipelineStatus.Succeeded)] + [InlineData("completed", "failure", GitPipelineStatus.Failed)] + [InlineData("completed", "timed_out", GitPipelineStatus.Failed)] + [InlineData("completed", "startup_failure", GitPipelineStatus.Failed)] + [InlineData("completed", "cancelled", GitPipelineStatus.Cancelled)] + [InlineData("completed", "skipped", GitPipelineStatus.Skipped)] + [InlineData("completed", "neutral", GitPipelineStatus.Unknown)] + [InlineData("completed", null, GitPipelineStatus.Unknown)] + [InlineData("queued", null, GitPipelineStatus.Queued)] + [InlineData("requested", null, GitPipelineStatus.Queued)] + [InlineData("waiting", null, GitPipelineStatus.Queued)] + [InlineData("pending", null, GitPipelineStatus.Queued)] + [InlineData("in_progress", null, GitPipelineStatus.Running)] + [InlineData("something_else", null, GitPipelineStatus.Unknown)] + [InlineData(null, null, GitPipelineStatus.Unknown)] + public void Map_TranslatesStatusAndConclusion(string? status, string? conclusion, GitPipelineStatus expected) + { + GitHubPipelineStatusMapper.Map(status, conclusion).Should().Be(expected); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRepositoryServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRepositoryServiceTests.cs new file mode 100644 index 0000000..ffcf6b5 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRepositoryServiceTests.cs @@ -0,0 +1,227 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubRepositoryServiceTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + private static readonly GitRepositoryRef Template = new("platform", "template-service"); + + [Fact] + public async Task CreateFromTemplate_MapsTheCreatedRepository() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/platform/template-service/generate").UsingPost()) + .RespondWith(Json.Created(RepoJson("acme", "billing"))); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.CreateFromTemplateAsync(harness.AppConnection(), new CreateRepositoryFromTemplate + { + Template = Template, + Namespace = "acme", + Name = "billing", + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Ref.Should().Be(Repo); + result.Value.CloneUrl.Should().Be("https://github.com/acme/billing.git"); + result.Value.DefaultBranch.Should().Be("main"); + } + + [Fact] + public async Task Get_MapsTheRepository() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing").UsingGet()) + .RespondWith(Json.Ok(RepoJson("acme", "billing"))); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.GetAsync(harness.AppConnection(), Repo); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.HtmlUrl.Should().Be("https://github.com/acme/billing"); + } + + [Fact] + public async Task Get_AbsentRepository_MapsToRepositoryNotFound() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.GetAsync(harness.AppConnection(), Repo)).Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task List_FallsBackToTheUserEndpointWhenOrgIsNotFound() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/repos").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + harness.Server.Given(Request.Create().WithPath("/api/v3/users/acme/repos").UsingGet()) + .RespondWith(Json.Ok(new[] { RepoJson("acme", "billing") })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListAsync(harness.AppConnection(), "acme"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle().Which.Ref.Name.Should().Be("billing"); + } + + [Fact] + public async Task FileExists_TrueWhenTheContentEndpointReturnsAnEntry() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/contents/README.md").UsingGet()) + .RespondWith(Json.Ok(new[] { new { type = "file", name = "README.md", path = "README.md", sha = "s" } })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.FileExistsAsync(harness.AppConnection(), Repo, "README.md")).Value.Should().BeTrue(); + } + + [Fact] + public async Task FileExists_FalseWhenAbsent() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/contents/missing.txt").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.FileExistsAsync(harness.AppConnection(), Repo, "missing.txt")).Value.Should().BeFalse(); + } + + [Fact] + public async Task ListCommits_MapsCommits() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/commits").UsingGet()) + .RespondWith(Json.Ok(new[] + { + new + { + sha = "abc", + html_url = "https://github.com/acme/billing/commit/abc", + commit = new { message = "init", author = new { name = "Dev", date = "2026-07-24T00:00:00Z" } }, + }, + })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListCommitsAsync(harness.AppConnection(), Repo, reference: null, limit: 10); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(); + result.Value[0].Sha.Should().Be("abc"); + result.Value[0].Message.Should().Be("init"); + } + + [Fact] + public async Task ListBranches_MapsBranches() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/branches").UsingGet()) + .RespondWith(Json.Ok(new[] { new { name = "main", commit = new { sha = "abc" }, @protected = true } })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListBranchesAsync(harness.AppConnection(), Repo); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(b => b.Name == "main" && b.Protected); + } + + [Fact] + public async Task CreateTag_CreatesAGitRef() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/git/refs").UsingPost()) + .RespondWith(Json.Created(new { @ref = "refs/tags/v1.0.0", @object = new { sha = "deadbeef" } })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.CreateTagAsync(harness.AppConnection(), Repo, "v1.0.0", commitSha: "deadbeef"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Name.Should().Be("v1.0.0"); + result.Value.Sha.Should().Be("deadbeef"); + } + + [Fact] + public async Task CreateTag_ResolvesTheDefaultBranchHeadWhenNoShaGiven() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing").UsingGet()) + .RespondWith(Json.Ok(RepoJson("acme", "billing"))); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/branches/main").UsingGet()) + .RespondWith(Json.Ok(new { name = "main", commit = new { sha = "headsha" }, @protected = false })); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/git/refs").UsingPost()) + .RespondWith(Json.Created(new { @ref = "refs/tags/v2", @object = new { sha = "headsha" } })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.CreateTagAsync(harness.AppConnection(), Repo, "v2"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Sha.Should().Be("headsha"); + } + + [Fact] + public async Task ListTags_MapsTags() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/tags").UsingGet()) + .RespondWith(Json.Ok(new[] { new { name = "v1", commit = new { sha = "abc" } } })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListTagsAsync(harness.AppConnection(), Repo); + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(t => t.Name == "v1"); + } + + [Fact] + public async Task CreateRelease_MapsTheRelease() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/releases").UsingPost()) + .RespondWith(Json.Created(new { id = 77, tag_name = "v1.0.0", html_url = "https://github.com/acme/billing/releases/v1.0.0" })); + var service = new GitHubRepositoryService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.CreateReleaseAsync(harness.AppConnection(), Repo, new CreateGitRelease + { + TagName = "v1.0.0", + Body = "notes", + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("77"); + result.Value.TagName.Should().Be("v1.0.0"); + } + + [Fact] + public async Task Operation_PropagatesAClientProviderFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubRepositoryService(StubClientProvider.Failing(GitErrors.NotConfigured("github"))); + + (await service.GetAsync(harness.AppConnection(), Repo)).Error.Code.Should().Be("Git.NotConfigured"); + } + + private static object RepoJson(string owner, string name) => new + { + id = 1, + name, + full_name = $"{owner}/{name}", + @private = true, + html_url = $"https://github.com/{owner}/{name}", + clone_url = $"https://github.com/{owner}/{name}.git", + default_branch = "main", + owner = new { login = owner, id = 2 }, + }; +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRestExecutorTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRestExecutorTests.cs new file mode 100644 index 0000000..059b91c --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubRestExecutorTests.cs @@ -0,0 +1,202 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Http; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubRestExecutorTests +{ + private const string Token = "tok"; + + [Fact] + public async Task Get_DeserializesTheBody() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { name = "widget" })); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("widget"); + } + + [Fact] + public async Task Get_404WithRepositoryContext_MapsToRepositoryNotFound() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.ForRepository(new GitRepositoryRef("a", "b")), + CancellationToken.None); + + result.Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task Get_404WithNamespaceContext_MapsToNamespaceNotFound() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.ForNamespace("acme"), CancellationToken.None); + + result.Error.Code.Should().Be("Git.NamespaceNotFound"); + } + + [Fact] + public async Task Get_404WithNoContext_MapsToProviderRejected() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None); + + result.Error.Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public async Task Get_401_MapsToAuthenticationFailed() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(401)); + + (await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).Error.Code + .Should().Be("Git.AuthenticationFailed"); + } + + [Fact] + public async Task Get_429_MapsToThrottled() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(429)); + + (await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).Error.Code + .Should().Be("Git.Throttled"); + } + + [Fact] + public async Task Get_403RateLimited_MapsToThrottledWithRetryHint() + { + using var harness = new GitHubTestHarness(); + var reset = DateTimeOffset.UtcNow.AddSeconds(45).ToUnixTimeSeconds(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(403) + .WithHeader("x-ratelimit-remaining", "0") + .WithHeader("x-ratelimit-reset", reset.ToString())); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None); + + result.Error.Code.Should().Be("Git.Throttled"); + result.Error.Metadata.Should().ContainKey("retryAfterSeconds"); + } + + [Fact] + public async Task Get_422AlreadyExists_MapsToConflict() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(422).WithBody("name already exists on this account")); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", new GitRestErrorContext { ConflictResource = "acme/x" }, CancellationToken.None); + + result.Error.Code.Should().Be("Git.Conflict"); + } + + [Fact] + public async Task Get_500_MapsToProviderRejected() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(500).WithBody("boom")); + + var result = await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None); + + result.Error.Code.Should().Be("Git.ProviderRejected"); + result.Error.Metadata["statusCode"].Should().Be(500); + } + + [Fact] + public async Task Get_MalformedJson_MapsToMalformedResponse() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("this is not json")); + + (await harness.RestExecutor.GetAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).Error.Code + .Should().Be("GitHub.MalformedResponse"); + } + + [Fact] + public async Task Send_NoBody_Succeeds() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(204)); + + (await harness.RestExecutor.SendAsync( + HttpMethod.Put, harness.BaseUri, Token, "thing", new { a = 1 }, GitRestErrorContext.None, CancellationToken.None)) + .IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DeleteIdempotent_TreatsNotFoundAsSuccess() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + + (await harness.RestExecutor.DeleteIdempotentAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DeleteIdempotent_PropagatesRealFailures() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/thing").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(500)); + + (await harness.RestExecutor.DeleteIdempotentAsync( + harness.BaseUri, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task Get_NetworkError_MapsToNetworkError() + { + using var harness = new GitHubTestHarness(); + var unreachable = new Uri("http://127.0.0.1:1/"); + + (await harness.RestExecutor.GetAsync( + unreachable, Token, "thing", GitRestErrorContext.None, CancellationToken.None)).Error.Code + .Should().Be("GitHub.NetworkError"); + } + + private sealed class RestProbe + { + public string Name { get; init; } = string.Empty; + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubSecretSealerTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubSecretSealerTests.cs new file mode 100644 index 0000000..47d86b1 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubSecretSealerTests.cs @@ -0,0 +1,54 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text; +using Compendium.Adapters.GitHub.Security; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubSecretSealerTests +{ + private readonly GitHubSecretSealer _sealer = new(); + + [Fact] + public void Seal_ProducesCiphertext_ThatOpensBackToThePlaintext() + { + // Arrange: a fixed libsodium keypair, as GitHub's Actions public key would be. + var keyPair = Sodium.PublicKeyBox.GenerateKeyPair(); + var publicKeyBase64 = Convert.ToBase64String(keyPair.PublicKey); + const string plaintext = "s3cret-value"; + + // Act + var sealedBase64 = _sealer.Seal(plaintext, publicKeyBase64); + + // Assert: only the keypair holder can recover the value. + var opened = Sodium.SealedPublicKeyBox.Open( + Convert.FromBase64String(sealedBase64), keyPair.PrivateKey, keyPair.PublicKey); + Encoding.UTF8.GetString(opened).Should().Be(plaintext); + } + + [Fact] + public void Seal_IsNonDeterministic_ButAlwaysDecryptsToTheSameValue() + { + var keyPair = Sodium.PublicKeyBox.GenerateKeyPair(); + var publicKeyBase64 = Convert.ToBase64String(keyPair.PublicKey); + + var first = _sealer.Seal("value", publicKeyBase64); + var second = _sealer.Seal("value", publicKeyBase64); + + first.Should().NotBe(second, "sealed boxes use an ephemeral key, so ciphertext varies"); + Encoding.UTF8.GetString(Sodium.SealedPublicKeyBox.Open( + Convert.FromBase64String(second), keyPair.PrivateKey, keyPair.PublicKey)).Should().Be("value"); + } + + [Fact] + public void Seal_RejectsAMissingPublicKey() + { + var act = () => _sealer.Seal("value", " "); + act.Should().Throw(); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookIngestorTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookIngestorTests.cs new file mode 100644 index 0000000..7e4975c --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookIngestorTests.cs @@ -0,0 +1,212 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using Compendium.Adapters.GitHub.Webhooks; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubWebhookIngestorTests +{ + private const string Secret = "hook-secret"; + private readonly GitHubWebhookIngestor _ingestor = new(); + + [Fact] + public void Parse_InvalidSignature_IsRejectedFailClosed() + { + var delivery = Delivery("push", "{}", validSignature: false); + + var result = _ingestor.Parse(delivery, Secret); + + result.Error.Code.Should().Be("Git.WebhookSignatureInvalid"); + } + + [Fact] + public void Parse_MissingSignature_IsRejected() + { + var delivery = Delivery("push", "{}", includeSignature: false); + + _ingestor.Parse(delivery, Secret).Error.Code.Should().Be("Git.WebhookSignatureInvalid"); + } + + [Fact] + public void Parse_EmptySecret_IsRejected() + { + _ingestor.Parse(Delivery("push", "{}"), string.Empty).Error.Code.Should().Be("Git.WebhookSignatureInvalid"); + } + + [Fact] + public void Parse_MissingDeliveryId_IsMalformed() + { + var delivery = Delivery("push", "{}", includeDelivery: false); + + _ingestor.Parse(delivery, Secret).Error.Code.Should().Be("Git.MalformedDelivery"); + } + + [Fact] + public void Parse_MissingEventHeader_IsMalformed() + { + var delivery = Delivery("push", "{}", includeEvent: false); + + _ingestor.Parse(delivery, Secret).Error.Code.Should().Be("Git.MalformedDelivery"); + } + + [Fact] + public void Parse_MalformedJson_IsMalformed() + { + var delivery = Delivery("push", "not json"); + + _ingestor.Parse(delivery, Secret).Error.Code.Should().Be("Git.MalformedDelivery"); + } + + [Fact] + public void Parse_Push_ProducesAPushEventWithRepository() + { + var body = """{"ref":"refs/heads/main","after":"abc123","repository":{"full_name":"acme/billing"}}"""; + + var result = _ingestor.Parse(Delivery("push", body), Secret); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + var push = result.Value.Should().BeOfType().Subject; + push.Reference.Should().Be("refs/heads/main"); + push.HeadCommitSha.Should().Be("abc123"); + push.DeliveryId.Should().Be("d-1"); + push.Repository.Should().Be(new GitRepositoryRef("acme", "billing")); + } + + [Fact] + public void Parse_TagPush_ProducesATagPushedEvent() + { + var body = """{"ref":"refs/tags/v1.2.3","after":"tagsha","repository":{"full_name":"acme/billing"}}"""; + + var result = _ingestor.Parse(Delivery("push", body), Secret); + + var tag = result.Value.Should().BeOfType().Subject; + tag.Tag.Should().Be("v1.2.3"); + tag.CommitSha.Should().Be("tagsha"); + } + + [Fact] + public void Parse_PullRequest_ProducesAPullRequestChangedEvent() + { + var body = """ + {"action":"opened","number":42,"pull_request":{"head":{"ref":"feature"},"base":{"ref":"main"}}, + "repository":{"full_name":"acme/billing"}} + """; + + var result = _ingestor.Parse(Delivery("pull_request", body), Secret); + + var pr = result.Value.Should().BeOfType().Subject; + pr.Action.Should().Be("opened"); + pr.Number.Should().Be(42); + pr.SourceReference.Should().Be("feature"); + pr.TargetReference.Should().Be("main"); + } + + [Fact] + public void Parse_WorkflowRunCompleted_ProducesAPipelineRunCompletedEvent() + { + var body = """ + {"action":"completed","workflow_run":{"id":555,"name":"CI","status":"completed","conclusion":"failure","head_branch":"main"}, + "repository":{"full_name":"acme/billing"}} + """; + + var result = _ingestor.Parse(Delivery("workflow_run", body), Secret); + + var run = result.Value.Should().BeOfType().Subject; + run.RunId.Should().Be("555"); + run.Pipeline.Should().Be("CI"); + run.Status.Should().Be(GitPipelineStatus.Failed); + run.Reference.Should().Be("main"); + } + + [Fact] + public void Parse_WorkflowRunInProgress_IsUnsupported() + { + var body = """{"action":"requested","workflow_run":{"id":1,"status":"in_progress"}}"""; + + _ingestor.Parse(Delivery("workflow_run", body), Secret).Value + .Should().BeOfType(); + } + + [Theory] + [InlineData("created", GitConnectionChangeKind.Installed)] + [InlineData("deleted", GitConnectionChangeKind.Uninstalled)] + [InlineData("suspend", GitConnectionChangeKind.Suspended)] + [InlineData("unsuspend", GitConnectionChangeKind.Unsuspended)] + [InlineData("new_permissions_accepted", GitConnectionChangeKind.RepositoriesChanged)] + public void Parse_Installation_MapsTheChangeKind(string action, GitConnectionChangeKind expected) + { + var body = "{\"action\":\"" + action + + "\",\"installation\":{\"id\":777,\"account\":{\"login\":\"acme\",\"type\":\"Organization\"}}}"; + + var result = _ingestor.Parse(Delivery("installation", body), Secret); + + var change = result.Value.Should().BeOfType().Subject; + change.Change.Should().Be(expected); + change.Namespace.Should().Be("acme"); + change.InstallationId.Should().Be("777"); + change.AccountType.Should().Be(GitAccountType.Organization); + } + + [Fact] + public void Parse_InstallationRepositories_IsRepositoriesChanged() + { + var body = """ + {"action":"added","installation":{"id":777,"account":{"login":"octocat","type":"User"}}} + """; + + var result = _ingestor.Parse(Delivery("installation_repositories", body), Secret); + + var change = result.Value.Should().BeOfType().Subject; + change.Change.Should().Be(GitConnectionChangeKind.RepositoriesChanged); + change.AccountType.Should().Be(GitAccountType.User); + } + + [Fact] + public void Parse_UnknownEvent_IsUnsupported() + { + var result = _ingestor.Parse(Delivery("star", "{}"), Secret); + + result.Value.Should().BeOfType() + .Which.ProviderEventType.Should().Be("star"); + } + + private static GitWebhookDelivery Delivery( + string eventType, + string body, + bool validSignature = true, + bool includeSignature = true, + bool includeEvent = true, + bool includeDelivery = true) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (includeSignature) + { + headers["X-Hub-Signature-256"] = validSignature ? Sign(body, Secret) : "sha256=deadbeef"; + } + + if (includeEvent) + { + headers["X-GitHub-Event"] = eventType; + } + + if (includeDelivery) + { + headers["X-GitHub-Delivery"] = "d-1"; + } + + return new GitWebhookDelivery { Body = body, Headers = headers }; + } + + private static string Sign(string body, string secret) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + return "sha256=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))).ToLowerInvariant(); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookServiceTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookServiceTests.cs new file mode 100644 index 0000000..4fa9dbe --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GitHubWebhookServiceTests.cs @@ -0,0 +1,121 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class GitHubWebhookServiceTests +{ + private const string HookUrl = "https://platform.example/webhooks/git"; + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + private static EnsureGitWebhook Request_() => new() + { + Url = new Uri(HookUrl), + Secret = "shh", + Events = ["push"], + }; + + [Fact] + public async Task Ensure_CreatesARepositoryHookWhenNoneMatches() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks").UsingGet()) + .RespondWith(Json.Ok(Array.Empty())); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks").UsingPost()) + .RespondWith(Json.Created(HookJson(10))); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.EnsureAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo), Request_()); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("10"); + result.Value.Url.Should().Be(new Uri(HookUrl)); + } + + [Fact] + public async Task Ensure_UpdatesTheExistingHookMatchedByUrl() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks").UsingGet()) + .RespondWith(Json.Ok(new[] { HookJson(10) })); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks/10").UsingPatch()) + .RespondWith(Json.Ok(HookJson(10))); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.EnsureAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo), Request_()); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("10"); + } + + [Fact] + public async Task Ensure_CreatesAnOrganizationHook() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/hooks").UsingGet()) + .RespondWith(Json.Ok(Array.Empty())); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/hooks").UsingPost()) + .RespondWith(Json.Created(HookJson(20))); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.EnsureAsync(harness.AppConnection(), new GitWebhookTarget.Namespace("acme"), Request_()); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Id.Should().Be("20"); + } + + [Fact] + public async Task List_MapsRepositoryHooks() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks").UsingGet()) + .RespondWith(Json.Ok(new[] { HookJson(10) })); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + var result = await service.ListAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo)); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(s => s.Id == "10"); + } + + [Fact] + public async Task Delete_RemovesTheHook() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/repos/acme/billing/hooks/10").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(204)); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.DeleteAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo), "10")) + .IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Delete_NonNumericId_IsANoOpSuccess() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.DeleteAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo), "abc")) + .IsSuccess.Should().BeTrue(); + harness.Server.LogEntries.Should().BeEmpty(); + } + + private static object HookJson(long id) => new + { + id, + name = "web", + active = true, + events = new[] { "push" }, + config = new { url = HookUrl, content_type = "json" }, + }; +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/GlobalUsings.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/GlobalUsings.cs new file mode 100644 index 0000000..71d6dab --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/GlobalUsings.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Abstractions.Git; +global using Compendium.Abstractions.Git.AccessControl; +global using Compendium.Abstractions.Git.Capabilities; +global using Compendium.Abstractions.Git.CiConfiguration; +global using Compendium.Abstractions.Git.Connections; +global using Compendium.Abstractions.Git.Environments; +global using Compendium.Abstractions.Git.Pipelines; +global using Compendium.Abstractions.Git.Protection; +global using Compendium.Abstractions.Git.Provisioning; +global using Compendium.Abstractions.Git.Repositories; +global using Compendium.Abstractions.Git.Webhooks; +global using Compendium.Core.Results; +global using FluentAssertions; +global using Xunit; diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/GitHubTestHarness.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/GitHubTestHarness.cs new file mode 100644 index 0000000..91b438e --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/GitHubTestHarness.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Security.Cryptography; +using Compendium.Adapters.GitHub.Auth; +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.Http; +using Compendium.Adapters.GitHub.Security; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using WireMock.Server; + +namespace Compendium.Adapters.GitHub.Tests.Infrastructure; + +/// +/// A WireMock-backed harness that wires the adapter's REST-facing collaborators +/// (token service, REST executor, credential broker) against a local mock GitHub +/// API. Disposable — stops the WireMock server. +/// +internal sealed class GitHubTestHarness : IDisposable +{ + public GitHubTestHarness() + { + Server = WireMockServer.Start(); + BaseUri = new Uri(Server.Url!); + + var pem = CreateRsaPrivateKeyPem(); + Options = new GitHubAdapterOptions + { + ApiBaseUrl = BaseUri, + DefaultApp = new GitHubAppRegistration + { + AppId = "1001", + AppSlug = "compendium-app", + PrivateKeyPem = pem, + WebhookSecret = "hook-secret", + }, + }; + + HttpClientFactory = new TestHttpClientFactory(); + RestExecutor = new GitHubRestExecutor(HttpClientFactory); + TokenService = new GitHubAppTokenService(HttpClientFactory, NullLogger.Instance); + Broker = new GitHubCredentialBroker(MicrosoftOptions.Create(Options), TokenService, RestExecutor); + Sealer = new GitHubSecretSealer(); + } + + public WireMockServer Server { get; } + + public Uri BaseUri { get; } + + public GitHubAdapterOptions Options { get; } + + public TestHttpClientFactory HttpClientFactory { get; } + + public GitHubRestExecutor RestExecutor { get; } + + public GitHubAppTokenService TokenService { get; } + + public GitHubCredentialBroker Broker { get; } + + public GitHubSecretSealer Sealer { get; } + + /// A connection carrying a personal access token (pass-through mint, no HTTP). + public GitConnection PatConnection(string token = "pat_test") => new() + { + Provider = "github", + ServerUrl = BaseUri, + Credential = new GitCredential.PersonalAccessToken(token), + }; + + /// A connection carrying an app installation (mints via the token service). + public GitConnection AppConnection(string installationId = "555") => new() + { + Provider = "github", + ServerUrl = BaseUri, + Credential = new GitCredential.AppInstallation(installationId), + }; + + /// A real Octokit client pointed at the WireMock server. + public Octokit.IGitHubClient OctokitClient() => + new Octokit.GitHubClient(new Octokit.ProductHeaderValue("compendium-test"), BaseUri) + { + Credentials = new Octokit.Credentials("test-token"), + }; + + public void Dispose() => Server.Stop(); + + private static string CreateRsaPrivateKeyPem() + { + using var rsa = RSA.Create(2048); + return rsa.ExportPkcs8PrivateKeyPem(); + } +} + +/// Static alias so the harness can new up without a name clash. +internal static class MicrosoftOptions +{ + public static IOptions Create(T value) + where T : class, new() => Microsoft.Extensions.Options.Options.Create(value); +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/Json.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/Json.cs new file mode 100644 index 0000000..8df30f1 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/Json.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests.Infrastructure; + +/// +/// WireMock JSON response builders that set Content-Type: application/json +/// — WireMock's WithBodyAsJson alone leaves it unset, which stops Octokit +/// from deserializing nested objects. +/// +internal static class Json +{ + public static IResponseBuilder Ok(object body) => Status(200, body); + + public static IResponseBuilder Created(object body) => Status(201, body); + + public static IResponseBuilder Status(int statusCode, object body) => + Response.Create().WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json").WithBodyAsJson(body); +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/StubClientProvider.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/StubClientProvider.cs new file mode 100644 index 0000000..0328c39 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/StubClientProvider.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Http; +using Octokit; + +namespace Compendium.Adapters.GitHub.Tests.Infrastructure; + +/// +/// An that hands back a fixed Octokit client +/// (real, pointed at WireMock) or a preset failure — so the Octokit-backed +/// services can be exercised against recorded HTTP responses. +/// +internal sealed class StubClientProvider : IGitHubClientProvider +{ + private readonly Result _result; + + private StubClientProvider(Result result) => _result = result; + + public static StubClientProvider Returning(IGitHubClient client) => + new(Result.Success(client)); + + public static StubClientProvider Failing(Error error) => + new(Result.Failure(error)); + + public Task> GetClientAsync(GitConnection connection, CancellationToken cancellationToken) => + Task.FromResult(_result); +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/TestHttpClientFactory.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/TestHttpClientFactory.cs new file mode 100644 index 0000000..a1371ed --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/Infrastructure/TestHttpClientFactory.cs @@ -0,0 +1,17 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.GitHub.Tests.Infrastructure; + +/// +/// Minimal for tests. The adapter builds absolute +/// request URIs itself, so a plain client with no base address suffices. +/// +internal sealed class TestHttpClientFactory : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => new(); +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/NoSassyLiteralsTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/NoSassyLiteralsTests.cs new file mode 100644 index 0000000..95cc4df --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/NoSassyLiteralsTests.cs @@ -0,0 +1,70 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Runtime.CompilerServices; + +namespace Compendium.Adapters.GitHub.Tests; + +/// +/// The package is published publicly, so no sassy-solutions-specific literal may +/// leak into it. This scans the adapter's own source (skipping the standard +/// copyright header comments, which carry the license attribution) for the org +/// slug, the bot login, and the platform App id. +/// +public sealed class NoSassyLiteralsTests +{ + private static readonly string[] Forbidden = ["sassy", "nxs-bot", "3654042"]; + + [Fact] + public void AdapterSource_ContainsNoSassySpecificLiterals() + { + var sourceRoot = AdapterSourceRoot(); + Directory.Exists(sourceRoot).Should().BeTrue($"expected the adapter source at {sourceRoot}"); + + var offenders = new List(); + + foreach (var file in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + || file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + { + continue; + } + + var lineNumber = 0; + foreach (var line in File.ReadLines(file)) + { + lineNumber++; + var trimmed = line.TrimStart(); + + // Skip comment lines (the copyright header carries the license attribution). + if (trimmed.StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + foreach (var token in Forbidden) + { + if (line.Contains(token, StringComparison.OrdinalIgnoreCase)) + { + offenders.Add($"{Path.GetFileName(file)}:{lineNumber} -> '{token}'"); + } + } + } + } + + offenders.Should().BeEmpty("the published package must not carry sassy-solutions-specific literals"); + } + + private static string AdapterSourceRoot([CallerFilePath] string? thisFile = null) + { + // thisFile: tests/Unit/Compendium.Adapters.GitHub.Tests/NoSassyLiteralsTests.cs + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var repoRoot = Path.GetFullPath(Path.Combine(testProjectDir, "..", "..", "..")); + return Path.Combine(repoRoot, "src", "Adapters", "Compendium.Adapters.GitHub"); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceCollectionExtensionsTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..a68720b --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,76 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Configuration; +using Compendium.Adapters.GitHub.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Compendium.Adapters.GitHub.Tests; + +public sealed class ServiceCollectionExtensionsTests +{ + private static ServiceProvider Build(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddGitHubGitServer(configure); + return services.BuildServiceProvider(); + } + + [Fact] + public void AddGitHubGitServer_RegistersTheServerForProviderDispatch() + { + using var provider = Build(); + + var servers = provider.GetServices().ToList(); + + servers.Should().ContainSingle(s => s.Provider == "github"); + servers[0].Capabilities.Provider.Should().Be("github"); + } + + [Fact] + public void AddGitHubGitServer_RegistersEveryConcernScopedPort() + { + using var provider = Build(); + + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddGitHubGitServer_TheSameFacadeBacksTheEnumerableAndThePorts() + { + using var provider = Build(); + + var facade = provider.GetServices().Single(); + provider.GetRequiredService().Should().BeSameAs(facade.Repositories); + } + + [Fact] + public void AddGitHubGitServer_AppliesTheConfigureCallback() + { + using var provider = Build(o => o.DefaultApp.AppId = "12345"); + + provider.GetRequiredService>().Value.DefaultApp.AppId.Should().Be("12345"); + } + + [Fact] + public void AddGitHubGitServer_ThrowsOnNullServices() + { + var act = () => ((IServiceCollection)null!).AddGitHubGitServer(); + act.Should().Throw(); + } +} diff --git a/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceErrorPathTests.cs b/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceErrorPathTests.cs new file mode 100644 index 0000000..c9431a4 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.GitHub.Tests/ServiceErrorPathTests.cs @@ -0,0 +1,225 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Adapters.GitHub.Services; +using Compendium.Adapters.GitHub.Tests.Infrastructure; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Compendium.Adapters.GitHub.Tests; + +/// +/// Auth-failure, provider-error, and edge-case paths across the services — the +/// branches the happy-path suites do not reach. +/// +public sealed class ServiceErrorPathTests +{ + private static readonly GitRepositoryRef Repo = new("acme", "billing"); + + // A connection whose credential cannot mint a token (unknown app registration). + private static GitConnection BadAuth(GitHubTestHarness harness) => + harness.AppConnection() with { Credential = new GitCredential.AppInstallation("1", "missing") }; + + // ---- REST services: authorization failures ---- + + [Fact] + public async Task CiConfiguration_SetSecrets_PropagatesAuthFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubCiConfigurationService(harness.Broker, harness.RestExecutor, harness.Sealer); + + (await service.SetSecretsAsync(BadAuth(harness), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["A"] = "b" })).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task CiConfiguration_SetSecrets_PropagatesAnUploadFailure() + { + using var harness = new GitHubTestHarness(); + var publicKey = Convert.ToBase64String(Sodium.PublicKeyBox.GenerateKeyPair().PublicKey); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/public-key").UsingGet()) + .RespondWith(Json.Ok(new { key_id = "kid", key = publicKey })); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/secrets/A").UsingPut()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubCiConfigurationService(harness.Broker, harness.RestExecutor, harness.Sealer); + + (await service.SetSecretsAsync(harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["A"] = "b" })).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task CiConfiguration_EnvironmentScope_PropagatesRepositoryIdFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + var service = new GitHubCiConfigurationService(harness.Broker, harness.RestExecutor, harness.Sealer); + + (await service.SetSecretsAsync(harness.PatConnection(), new GitConfigurationScope.Environment(Repo, "prod"), + new Dictionary { ["A"] = "b" })).Error.Code.Should().Be("Git.RepositoryNotFound"); + } + + [Fact] + public async Task CiConfiguration_SetVariables_NonConflictFailure_Propagates() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubCiConfigurationService(harness.Broker, harness.RestExecutor, harness.Sealer); + + (await service.SetVariablesAsync(harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["A"] = "b" })).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task CiConfiguration_SetVariables_UpdateFailureAfterConflict_Propagates() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(409).WithBody("already exists")); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/actions/variables/A").UsingPatch()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubCiConfigurationService(harness.Broker, harness.RestExecutor, harness.Sealer); + + (await service.SetVariablesAsync(harness.PatConnection(), new GitConfigurationScope.Repository(Repo), + new Dictionary { ["A"] = "b" })).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task Environment_Ensure_PropagatesAuthFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubEnvironmentService(harness.Broker, harness.RestExecutor); + + (await service.EnsureAsync(BadAuth(harness), Repo, new EnsureGitEnvironment { Name = "prod" })) + .Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task Environment_List_PropagatesProviderFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/environments").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubEnvironmentService(harness.Broker, harness.RestExecutor); + + (await service.ListAsync(harness.PatConnection(), Repo)).IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task BranchPolicy_Apply_PropagatesAuthFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubBranchPolicyService(harness.Broker, harness.RestExecutor); + + (await service.ApplyAsync(BadAuth(harness), Repo, new GitBranchPolicyRequest { Pattern = "main" })) + .Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task BranchPolicy_Apply_PropagatesListFailure() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubBranchPolicyService(harness.Broker, harness.RestExecutor); + + (await service.ApplyAsync(harness.PatConnection(), Repo, new GitBranchPolicyRequest { Pattern = "main" })) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task BranchPolicy_Apply_WithAllRuleOptions_BuildsTheRuleset() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingGet()) + .RespondWith(Json.Ok(Array.Empty())); + harness.Server.Given(Request.Create().WithPath("/repos/acme/billing/rulesets").UsingPost()) + .RespondWith(Json.Created(new { id = 1, name = "compendium:release/*" })); + var service = new GitHubBranchPolicyService(harness.Broker, harness.RestExecutor); + + var result = await service.ApplyAsync(harness.PatConnection(), Repo, new GitBranchPolicyRequest + { + Pattern = "release/*", + RequirePullRequest = false, + BlockForcePush = false, + BlockDeletion = false, + RequireLinearHistory = true, + EnforceForAdmins = true, + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + var body = harness.Server.LogEntries.Last(e => e.RequestMessage.Method == "POST").RequestMessage.Body ?? string.Empty; + body.Should().Contain("required_linear_history"); + body.Should().NotContain("bypass_actors"); + } + + // ---- Octokit services: client-provider failures and error mapping ---- + + [Fact] + public async Task Pipeline_Trigger_PropagatesAClientProviderFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubPipelineService(StubClientProvider.Failing(GitErrors.NotConfigured("github"))); + + (await service.TriggerAsync(harness.AppConnection(), Repo, new TriggerGitPipeline + { + Pipeline = "ci.yml", + Reference = "main", + })).Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task AccessControl_EnsureTeam_MapsAProviderError() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/teams/eng").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(500)); + var service = new GitHubAccessControlService(StubClientProvider.Returning(harness.OctokitClient())); + + (await service.EnsureTeamAsync(harness.AppConnection(), "acme", new EnsureGitTeam { Name = "eng" })) + .Error.Code.Should().Be("Git.ProviderRejected"); + } + + [Fact] + public async Task AccessControl_PropagatesAClientProviderFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubAccessControlService(StubClientProvider.Failing(GitErrors.NotConfigured("github"))); + + (await service.SetUserRepositoryRoleAsync(harness.AppConnection(), Repo, "bob", GitRepositoryRole.Read)) + .Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task Webhook_PropagatesAClientProviderFailure() + { + using var harness = new GitHubTestHarness(); + var service = new GitHubWebhookService(StubClientProvider.Failing(GitErrors.NotConfigured("github"))); + + (await service.ListAsync(harness.AppConnection(), new GitWebhookTarget.Repository(Repo))) + .Error.Code.Should().Be("Git.NotConfigured"); + } + + [Fact] + public async Task Webhook_DeletesAndListsOrganizationHooks() + { + using var harness = new GitHubTestHarness(); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/hooks/9").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(404)); + harness.Server.Given(Request.Create().WithPath("/api/v3/orgs/acme/hooks").UsingGet()) + .RespondWith(Json.Ok(new[] + { + new { id = 9, name = "web", active = true, events = new[] { "push" }, config = new { url = "https://p/x" } }, + })); + var service = new GitHubWebhookService(StubClientProvider.Returning(harness.OctokitClient())); + var target = new GitWebhookTarget.Namespace("acme"); + + (await service.DeleteAsync(harness.AppConnection(), target, "9")).IsSuccess.Should().BeTrue(); + (await service.ListAsync(harness.AppConnection(), target)).Value.Should().ContainSingle(s => s.Id == "9"); + } +} From ce4811e52d2bd875fb7200ef30df29a207210973 Mon Sep 17 00:00:00 2001 From: sacha Date: Fri, 24 Jul 2026 11:42:36 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(testing):=20keep=20Compendium.Testing?= =?UTF-8?q?=20a=20library=20=E2=80=94=20xunit=20metapackage=20flipped=20Is?= =?UTF-8?q?TestProject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotnet test on the solution tried to run the shipped Compendium.Testing.dll as a test assembly (no Test.Sdk/testhost entry point) and failed CI. Pin IsTestProject=false explicitly: the package ships the git contract-test kit, it is not itself a test project. Claude-Session: https://claude.ai/code/session_01VFDn7gCH7vdvT4eoNhsMLr --- src/Testing/Compendium.Testing/Compendium.Testing.csproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Testing/Compendium.Testing/Compendium.Testing.csproj b/src/Testing/Compendium.Testing/Compendium.Testing.csproj index 2124504..8e5d33b 100644 --- a/src/Testing/Compendium.Testing/Compendium.Testing.csproj +++ b/src/Testing/Compendium.Testing/Compendium.Testing.csproj @@ -4,7 +4,12 @@ Compendium.Testing Compendium.Testing Compendium.Testing - Testing utilities for Compendium Framework: TestContainers integration (PostgreSQL/Redis), test data builders (AutoFixture/Bogus), aggregate testing helpers for Event Sourcing. + Testing utilities for Compendium Framework: TestContainers integration (PostgreSQL/Redis), test data builders (AutoFixture/Bogus), aggregate testing helpers for Event Sourcing, and the git-server contract-test kit (InMemoryGitServer + GitServerContractTests). + + false