├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── renovate.json ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 20_feature_request.yml │ ├── 30_question.yml │ └── 10_bug_report.yml ├── zizmor.yml ├── update-dotnet-sdk.json ├── lighthouse.config.json ├── ISSUE_TEMPLATE.md ├── actionlint-matcher.json ├── workflows │ ├── dependency-review.yml │ ├── ossf-scorecard.yml │ └── codeql.yml └── CONTRIBUTING.md ├── .npmrc ├── src ├── Costellobot │ ├── .npmrc │ ├── wwwroot │ │ ├── robots.txt │ │ ├── robots933456.txt │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── humans.txt │ │ └── manifest.webmanifest │ ├── .prettierignore │ ├── Octokit │ │ ├── IGitHubClientForApp.cs │ │ ├── IGitHubClientForUser.cs │ │ ├── IGitHubClientForInstallation.cs │ │ ├── DeploymentReviewer.cs │ │ ├── PendingDeployment.cs │ │ ├── PendingDeploymentEnvironment.cs │ │ ├── ReviewDeploymentProtectionRule.cs │ │ ├── IWorkflowRunsClient.cs │ │ ├── GitHubClientAdapter.cs │ │ ├── UserCredentialStore.cs │ │ ├── CredentialStore.cs │ │ ├── InstallationCredentialStore.cs │ │ ├── WorkflowRunsClient.cs │ │ └── IGitHubClientExtensions.cs │ ├── GitHubWebhookQueue.cs │ ├── ClientLogQueue.cs │ ├── Handlers │ │ ├── IHandlerFactory.cs │ │ ├── IHandler.cs │ │ ├── NullHandler.cs │ │ └── HandlerFactory.cs │ ├── Models │ │ ├── LayoutModel.cs │ │ ├── DeliveriesModel.cs │ │ ├── TrustedDependency.cs │ │ ├── ConfigurationModel.cs │ │ ├── ErrorModel.cs │ │ ├── Badge.cs │ │ ├── WebhookDelivery.cs │ │ └── DeliveryModel.cs │ ├── scripts │ │ ├── main.ts │ │ ├── App.test.ts │ │ └── Telemetry.ts │ ├── Properties │ │ └── launchSettings.json │ ├── GrafanaOptions.cs │ ├── GitHubInstallationOptions.cs │ ├── RegistryOptions.cs │ ├── DependencyEcosystem.cs │ ├── tsconfig.json │ ├── appsettings.Development.json │ ├── Authorization │ │ ├── HealthProbeRequirement.cs │ │ ├── AdministratorRequirement.cs │ │ ├── CostellobotAdminAttribute.cs │ │ ├── AdministratorHandler.cs │ │ ├── HealthOperatorHandler.cs │ │ ├── ClaimsPrincipalExtensions.cs │ │ └── HealthProbeHandler.cs │ ├── Slices │ │ ├── Error.cshtml │ │ ├── _ViewImports.cshtml │ │ ├── SignIn.cshtml │ │ └── Home.cshtml │ ├── GoogleHttpClientFactoryAdapter.cs │ ├── GitHubMessage.cs │ ├── PyroscopeK6Middleware.cs │ ├── Program.cs │ ├── GitHubAppOptions.cs │ ├── SiteOptions.cs │ ├── IGitHubClientFactory.cs │ ├── TrustedEntitiesOptions.cs │ ├── vitest.config.ts │ ├── IWebhookClient.cs │ ├── GoogleOptions.cs │ ├── Registries │ │ ├── PackageRegistry.cs │ │ ├── IPackageRegistry.cs │ │ ├── GitHubReleasePackageRegistry.cs │ │ └── GitSubmodulePackageRegistry.cs │ ├── HttpRequestExtensions.cs │ ├── ClientLogMessage.cs │ ├── GitHubEvent.cs │ ├── GitHubWebhookHub.cs │ ├── ClientLogBroadcastService.cs │ ├── ILoggingBuilderExtensions.cs │ ├── GitHubClientFactory.cs │ ├── ITrustStore.cs │ ├── GitHubOptions.cs │ ├── RepositoryId.cs │ ├── DeploymentRules │ │ ├── DeploymentRule.cs │ │ ├── IDeploymentRule.cs │ │ ├── ConfigurationDeploymentRule.cs │ │ └── PublicHolidayDeploymentRule.cs │ ├── CostellobotMetrics.cs │ ├── ClientLoggingProvider.cs │ ├── WebhookOptions.cs │ ├── RazorSliceExtensions.cs │ ├── IssueId.cs │ ├── webpack.config.cjs │ ├── GitHubWebhookContext.cs │ ├── ChannelQueue`1.cs │ ├── WellKnownGitHubEvents.cs │ └── package.json └── Costellobot.AppHost │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── Properties │ └── launchSettings.json │ └── Costellobot.AppHost.csproj ├── .markdownlint.json ├── .aspire └── settings.json ├── perf └── Costellobot.Benchmarks │ ├── Properties │ └── launchSettings.json │ ├── Costellobot.Benchmarks.csproj │ └── Program.cs ├── .vscode ├── settings.json ├── extensions.json ├── tasks.json └── launch.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── NuGet.config ├── exclusion.dic ├── global.json ├── SECURITY.md ├── tests ├── Costellobot.EndToEndTests │ ├── AppCollection.cs │ ├── EndToEndTest.cs │ ├── Costellobot.EndToEndTests.csproj │ ├── UITests.cs │ └── AppFixture.cs └── Costellobot.Tests │ ├── Infrastructure │ ├── AppCollection.cs │ ├── HttpServerCollection.cs │ ├── BrowserFixtureOptions.cs │ ├── RemoteAuthorizationEventsFilter.cs │ ├── HttpRequestInterceptionFilter.cs │ ├── AntiforgeryTokens.cs │ ├── ShouldlyTaskExtensions.cs │ ├── IWebHostBuilderExtensions.cs │ ├── AntiforgeryTokenController.cs │ ├── LoopbackOAuthEvents.cs │ ├── AppFixtureExtensions.cs │ ├── InMemoryTrustStore.cs │ └── ApplicationCache.cs │ ├── GitDiffParserTests.Docker.OneFile.diff │ ├── Builders │ ├── ResponseBuilderExtensions.cs │ ├── DeploymentStatusBuilder.cs │ ├── LabelBuilder.cs │ ├── CheckRunsResponseBuilder.cs │ ├── CheckSuitesResponseBuilder.cs │ ├── WorkflowRunsResponseBuilder.cs │ ├── WorkflowRunBuilder.cs │ ├── AccessTokenBuilder.cs │ ├── CommentBuilder.cs │ ├── InstallationRepositoriesBuilder.cs │ ├── ResponseBuilder.cs │ ├── DeploymentBuilder.cs │ ├── CompareResultBuilder.cs │ ├── GitHubAppBuilder.cs │ ├── UserBuilder.cs │ ├── PendingDeploymentBuilder.cs │ ├── CheckRunBuilder.cs │ ├── GitHubCommitBuilder.cs │ ├── GitCommitBuilder.cs │ ├── PullRequestReviewBuilder.cs │ ├── CheckSuiteBuilder.cs │ ├── IssueBuilder.cs │ └── RepositoryBuilder.cs │ ├── Handlers │ └── NullHandlerTests.cs │ ├── Pages │ ├── ConfigurationPage.cs │ └── DeliveryPage.cs │ ├── GitDiffParserTests.npm.None.diff │ ├── CategoryAttribute.cs │ ├── ObjectExtensions.cs │ ├── GitDiffParserTests.NuGet.OneFile.diff │ ├── Bundles │ ├── oauth-http-bundle.json │ ├── ruby-gems.json │ ├── grafana.json │ └── github-submodules.json │ ├── Drivers │ ├── RepositoryDispatchDriver.cs │ ├── IssueCommentDriver.cs │ ├── PullRequestReviewDriver.cs │ ├── PushDriver.cs │ ├── DeploymentProtectionRuleDriver.cs │ ├── CheckSuiteDriver.cs │ └── PullRequestDriver.cs │ ├── PublicHolidayProviderTests.cs │ ├── Registries │ ├── DockerPackageRegistryTests.cs │ ├── RubyGemsPackageRegistryTests.cs │ └── NuGetPackageRegistryTests.cs │ ├── GitDiffParserTests.npm.OnePackageLastLine.diff │ ├── costellobot-tests.pem │ ├── GitHubWebhookServiceTests.cs │ └── Costellobot.Tests.csproj ├── omnisharp.json ├── .gitignore ├── .vsconfig ├── Directory.Build.props ├── startvs.cmd ├── startvscode.cmd ├── docker-compose.yml ├── stylecop.json ├── Costellobot.ruleset └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @martincostello 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /src/Costellobot/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: * 3 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD040": false 4 | } 5 | -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/robots933456.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: * 3 | -------------------------------------------------------------------------------- /src/Costellobot/.prettierignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | node_modules/ 3 | obj/ 4 | wwwroot/ 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [martincostello] 2 | buy_me_a_coffee: martincostello 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /.aspire/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appHostPath": "../src/Costellobot.AppHost/Costellobot.AppHost.csproj" 3 | } 4 | -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/costellobot/HEAD/src/Costellobot/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/costellobot/HEAD/src/Costellobot/wwwroot/favicon.png -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/humans.txt: -------------------------------------------------------------------------------- 1 | This website was created by Martin Costello. 2 | 3 | Bluesky: @martincostello.com 4 | From: London, UK 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: yearly 7 | timezone: Europe/London 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/Costellobot.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Contact me 4 | url: https://martincostello.com/bluesky 5 | about: You can also contact me on Bluesky. 6 | -------------------------------------------------------------------------------- /perf/Costellobot.Benchmarks/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Costellobot.Benchmarks": { 4 | "commandName": "Project", 5 | "commandLineArgs": "" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | anonymous-definition: 3 | disable: true 4 | concurrency-limits: 5 | disable: true 6 | dependabot-cooldown: 7 | disable: true 8 | undocumented-permissions: 9 | disable: true 10 | -------------------------------------------------------------------------------- /src/Costellobot.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Aspire.Hosting.Dcp": "Warning", 6 | "Microsoft.AspNetCore": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dotnet.defaultSolution": "Costellobot.slnx", 3 | "terminal.integrated.defaultProfile.linux": "pwsh", 4 | "terminal.integrated.defaultProfile.osx": "pwsh", 5 | "terminal.integrated.defaultProfile.windows": "PowerShell" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "davidanson.vscode-markdownlint", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig", 6 | "github.vscode-github-actions", 7 | "ms-dotnettools.csharp", 8 | "ms-vscode.PowerShell" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/update-dotnet-sdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martincostello/github-automation/main/.github/update-dotnet-sdk-schema.json", 3 | "include-nuget-packages": "Aspire.,Microsoft.AspNetCore.,Microsoft.EntityFrameworkCore,Microsoft.Extensions.,System.Text.Json" 4 | } 5 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/IGitHubClientForApp.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public interface IGitHubClientForApp : IGitHubClient; 7 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/IGitHubClientForUser.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public interface IGitHubClientForUser : IGitHubClient; 7 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/IGitHubClientForInstallation.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public interface IGitHubClientForInstallation : IGitHubClient; 7 | -------------------------------------------------------------------------------- /.github/lighthouse.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "settings": { 4 | "skipAudits": [ 5 | "render-blocking-resources", 6 | "unused-css-rules", 7 | "unused-javascript", 8 | "uses-long-cache-ttl", 9 | "uses-text-compression" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubWebhookQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GitHubWebhookQueue : ChannelQueue; 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet build", 7 | "type": "shell", 8 | "group": "build", 9 | "presentation": { 10 | "reveal": "silent" 11 | }, 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/Costellobot/ClientLogQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed partial class ClientLogQueue : ChannelQueue 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/dotnet:latest@sha256:97961d9ef283e2404a79fb9bdca5837b6af57f5f8688556e21cbc5686af72770 2 | 3 | ARG INSTALL_NODE="true" 4 | ARG NODE_VERSION="lts/*" 5 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 6 | -------------------------------------------------------------------------------- /src/Costellobot/Handlers/IHandlerFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Handlers; 5 | 6 | public interface IHandlerFactory 7 | { 8 | IHandler Create(string? eventType); 9 | } 10 | -------------------------------------------------------------------------------- /src/Costellobot/Models/LayoutModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Models; 5 | 6 | public sealed class LayoutModel(string title) 7 | { 8 | public string Title { get; } = title; 9 | } 10 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Costellobot/Models/DeliveriesModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Models; 5 | 6 | public sealed record DeliveriesModel( 7 | string AppName, 8 | IReadOnlyList Deliveries); 9 | -------------------------------------------------------------------------------- /src/Costellobot/scripts/main.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { App } from './App'; 5 | 6 | document.addEventListener('DOMContentLoaded', async () => { 7 | const app = new App(); 8 | await app.initialize(); 9 | }); 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '' 3 | --- 4 | 5 | ## Expected behaviour 6 | 7 | 8 | 9 | ## Actual behaviour 10 | 11 | 12 | 13 | ## Steps to reproduce 14 | 15 | 16 | -------------------------------------------------------------------------------- /exclusion.dic: -------------------------------------------------------------------------------- 1 | analyzed 2 | analyzer 3 | antiforgery 4 | catalog 5 | codeql 6 | Costellobot 7 | Costellorg 8 | Dependabot 9 | deserializer 10 | dockerfile 11 | DotNet 12 | eslint 13 | GitHub 14 | Grafana 15 | monorepo 16 | Newtonsoft 17 | noreply 18 | NuGet 19 | Octokit 20 | OpenTelemetry 21 | Otlp 22 | Pyroscope 23 | SemVer 24 | serializer 25 | startup 26 | vstest 27 | Webhook 28 | xunit 29 | yaml 30 | -------------------------------------------------------------------------------- /src/Costellobot/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Costellobot": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "launchBrowser": true, 7 | "applicationUrl": "https://localhost:50001;http://localhost:50000", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.101", 4 | "allowPrerelease": false, 5 | "rollForward": "latestMajor", 6 | "paths": [ 7 | ".dotnet", 8 | "$host$" 9 | ], 10 | "errorMessage": "The required version of the .NET SDK could not be found. Please run ./build.ps1 to bootstrap the .NET SDK." 11 | }, 12 | "msbuild-sdks": { 13 | "Aspire.AppHost.Sdk": "13.1.0" 14 | } 15 | } -------------------------------------------------------------------------------- /src/Costellobot/Octokit/DeploymentReviewer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public sealed class DeploymentReviewer 7 | { 8 | public string Type { get; set; } = string.Empty; 9 | 10 | public User Reviewer { get; set; } = default!; 11 | } 12 | -------------------------------------------------------------------------------- /src/Costellobot/GrafanaOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GrafanaOptions 7 | { 8 | public string Token { get; set; } = string.Empty; 9 | 10 | public string Url { get; set; } = string.Empty; 11 | } 12 | -------------------------------------------------------------------------------- /src/Costellobot/Handlers/IHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.Handlers; 7 | 8 | public interface IHandler 9 | { 10 | Task HandleAsync(WebhookEvent message, CancellationToken cancellationToken); 11 | } 12 | -------------------------------------------------------------------------------- /src/Costellobot/Models/TrustedDependency.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Models; 5 | 6 | public sealed record TrustedDependency( 7 | string Id, 8 | string Version) 9 | { 10 | public DateTimeOffset? TrustedAt { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /src/Costellobot/scripts/App.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { describe, expect, test } from 'vitest'; 5 | import { App } from './App'; 6 | 7 | describe('App', () => { 8 | test('should be defined', () => { 9 | expect(App).toBeDefined(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubInstallationOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GitHubInstallationOptions 7 | { 8 | public string AppId { get; set; } = string.Empty; 9 | 10 | public string? Organization { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /src/Costellobot/RegistryOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class RegistryOptions 7 | { 8 | public Uri BaseAddress { get; set; } = default!; 9 | 10 | public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5); 11 | } 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To privately report a security vulnerability, please create a security advisory in the [repository's Security tab](https://github.com/martincostello/costellobot/security/advisories). 6 | 7 | Further details can be found in the [GitHub documentation](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). 8 | -------------------------------------------------------------------------------- /tests/Costellobot.EndToEndTests/AppCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class AppCollection : ICollectionFixture 8 | { 9 | public const string Name = "Application collection"; 10 | } 11 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/AppCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Infrastructure; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class AppCollection : ICollectionFixture 8 | { 9 | public const string Name = "Costellobot server collection"; 10 | } 11 | -------------------------------------------------------------------------------- /src/Costellobot/DependencyEcosystem.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public enum DependencyEcosystem 7 | { 8 | Unknown = 0, 9 | Unsupported, 10 | Docker, 11 | GitHubActions, 12 | Npm, 13 | NuGet, 14 | GitSubmodule, 15 | Ruby, 16 | GitHubRelease, 17 | } 18 | -------------------------------------------------------------------------------- /src/Costellobot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "inlineSources": true, 5 | "moduleResolution": "Node", 6 | "noEmitOnError": true, 7 | "noImplicitAny": true, 8 | "noImplicitOverride": true, 9 | "noImplicitThis": true, 10 | "outDir": "./wwwroot/static/js", 11 | "removeComments": false, 12 | "sourceMap": true, 13 | "target": "ES2015" 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | "wwwroot" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/HttpServerCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Infrastructure; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class HttpServerCollection : ICollectionFixture 8 | { 9 | public const string Name = "Costellobot HTTP server collection"; 10 | } 11 | -------------------------------------------------------------------------------- /src/Costellobot/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Aspire": { 3 | "Azure": { 4 | "Data": { 5 | "Tables": { 6 | "DisableHealthChecks": true 7 | } 8 | }, 9 | "Messaging": { 10 | "ServiceBus": { 11 | "HealthCheckQueueName": "" 12 | } 13 | } 14 | } 15 | }, 16 | "Logging": { 17 | "LogLevel": { 18 | "Default": "Information", 19 | "Azure": "Information", 20 | "Microsoft.AspNetCore": "Warning" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/HealthProbeRequirement.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | 6 | namespace MartinCostello.Costellobot.Authorization; 7 | 8 | /// 9 | /// A class representing the authorization requirement for health probes. 10 | /// 11 | public sealed class HealthProbeRequirement : IAuthorizationRequirement; 12 | -------------------------------------------------------------------------------- /omnisharp.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileOptions": { 3 | "systemExcludeSearchPatterns": [ 4 | "**/bin/**/*", 5 | "**/obj/**/*", 6 | "**/node_modules/**/*" 7 | ], 8 | "excludeSearchPatterns": [] 9 | }, 10 | "FormattingOptions": { 11 | "enableEditorConfigSupport": true 12 | }, 13 | "msbuild": { 14 | "MSBuildSDKsPath": ".\\.dotnet", 15 | "EnablePackageAutoRestore": true, 16 | "loadProjectsOnDemand": true 17 | }, 18 | "RoslynExtensionsOptions": { 19 | "enableAnalyzersSupport": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/AdministratorRequirement.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | 6 | namespace MartinCostello.Costellobot.Authorization; 7 | 8 | /// 9 | /// A class representing the authorization requirement for administrators. 10 | /// 11 | public sealed class AdministratorRequirement : IAuthorizationRequirement; 12 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/GitDiffParserTests.Docker.OneFile.diff: -------------------------------------------------------------------------------- 1 | diff --git a/Dockerfile b/Dockerfile 2 | index 882d5ae..32ec3c5 100644 3 | --- a/Dockerfile 4 | +++ b/Dockerfile 5 | @@ -1,4 +1,4 @@ 6 | -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0.301@sha256:faa2daf2b72cbe787ee1882d9651fa4ef3e938ee56792b8324516f5a448f3abe AS build 7 | +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0.301@sha256:b768b444028d3c531de90a356836047e48658cd1e26ba07a539a6f1a052a35d9 AS build 8 | ARG TARGETARCH 9 | 10 | COPY . /source 11 | -------------------------------------------------------------------------------- /.github/actionlint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "actionlint", 5 | "pattern": [ 6 | { 7 | "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4, 12 | "code": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/ResponseBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public static class ResponseBuilderExtensions 7 | { 8 | public static object Build(this IEnumerable builders) 9 | where T : ResponseBuilder 10 | { 11 | return builders.Select((p) => p.Build()).ToArray(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Costellobot/Slices/Error.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSlice 2 | @implements IUsesLayout<_Layout, LayoutModel> 3 | 4 | @{ 5 | string modifier = Model.IsClientError ? "secondary" : "danger"; 6 | } 7 | 8 |

@(Model.Subtitle)

9 |

@(Model.Message)

10 | 11 | @if (Model.ShowRequestId) 12 | { 13 |

14 | Request ID: @(Model.RequestId) 15 |

16 | } 17 | 18 | @functions { 19 | public LayoutModel LayoutModel => new(Model.Title); 20 | } 21 | -------------------------------------------------------------------------------- /src/Costellobot/Handlers/NullHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.Handlers; 7 | 8 | public sealed class NullHandler : IHandler 9 | { 10 | public static readonly NullHandler Instance = new(); 11 | 12 | public Task HandleAsync(WebhookEvent message, CancellationToken cancellationToken) 13 | => Task.CompletedTask; 14 | } 15 | -------------------------------------------------------------------------------- /src/Costellobot/GoogleHttpClientFactoryAdapter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Google.Apis.Http; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public sealed class GoogleHttpClientFactoryAdapter(IHttpMessageHandlerFactory handlerFactory) : HttpClientFactory 9 | { 10 | protected override HttpMessageHandler CreateHandler(CreateHttpClientArgs args) 11 | => handlerFactory.CreateHandler(); 12 | } 13 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/PendingDeployment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public sealed class PendingDeployment 7 | { 8 | public PendingDeploymentEnvironment Environment { get; set; } = default!; 9 | 10 | public long WaitTimer { get; set; } 11 | 12 | public DateTimeOffset? WaitTimerStartedAt { get; set; } 13 | 14 | public bool CurrentUserCanApprove { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/CostellobotAdminAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | 6 | namespace MartinCostello.Costellobot.Authorization; 7 | 8 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] 9 | public sealed class CostellobotAdminAttribute() 10 | : AuthorizeAttribute(AuthenticationEndpoints.AdminPolicyName); 11 | -------------------------------------------------------------------------------- /src/Costellobot/Models/ConfigurationModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit; 5 | 6 | namespace MartinCostello.Costellobot.Models; 7 | 8 | #pragma warning disable CA1724 9 | 10 | public sealed record ConfigurationModel( 11 | GitHubOptions GitHub, 12 | WebhookOptions Webhook, 13 | IReadOnlyDictionary InstallationRateLimits, 14 | MiscellaneousRateLimit? UserRateLimits); 15 | -------------------------------------------------------------------------------- /src/Costellobot/Slices/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSliceHttpResult 2 | 3 | @using System.Globalization 4 | @using Microsoft.AspNetCore.Razor 5 | @using Microsoft.AspNetCore.Http.HttpResults 6 | @using Microsoft.Extensions.Options 7 | @using Humanizer 8 | @using MartinCostello.Costellobot 9 | @using MartinCostello.Costellobot.Models 10 | @using MartinCostello.Costellobot.Slices 11 | @using RazorSlices 12 | 13 | @tagHelperPrefix __disable_tagHelpers__: 14 | @removeTagHelper *, Microsoft.AspNetCore.Mvc.Razor 15 | 16 | @inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery 17 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubMessage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GitHubMessage 7 | { 8 | public static string ContentType { get; } = "application/json"; 9 | 10 | public static string Subject { get; } = "github-webhook"; 11 | 12 | public required Dictionary Headers { get; init; } 13 | 14 | public required string Body { get; init; } 15 | } 16 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/PendingDeploymentEnvironment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public class PendingDeploymentEnvironment 7 | { 8 | public long Id { get; set; } 9 | 10 | public string NodeId { get; set; } = string.Empty; 11 | 12 | public string Name { get; set; } = string.Empty; 13 | 14 | public string Url { get; set; } = string.Empty; 15 | 16 | public string HtmlUrl { get; set; } = string.Empty; 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/DeploymentStatusBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class DeploymentStatusBuilder(string state) : ResponseBuilder 7 | { 8 | public string State { get; set; } = state; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | id = Id, 15 | state = State, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/ReviewDeploymentProtectionRule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public sealed class ReviewDeploymentProtectionRule(string environmentName, PendingDeploymentReviewState? state, string? comment) 7 | { 8 | public string EnvironmentName { get; } = environmentName; 9 | 10 | public StringEnum? State { get; } = state; 11 | 12 | public string? Comment { get; } = comment; 13 | } 14 | -------------------------------------------------------------------------------- /src/Costellobot/PyroscopeK6Middleware.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | internal sealed class PyroscopeK6Middleware(RequestDelegate next) 7 | { 8 | private readonly RequestDelegate _next = next; 9 | 10 | [System.Diagnostics.StackTraceHidden] 11 | public Task InvokeAsync(HttpContext context) => 12 | ApplicationTelemetry.ProfileAsync((_next, context), static (state) => state._next(state.context)); 13 | } 14 | -------------------------------------------------------------------------------- /src/Costellobot/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | builder.AddCostellobot(); 9 | 10 | var app = builder.Build(); 11 | 12 | app.UseCostellobot(); 13 | 14 | app.Run(); 15 | 16 | namespace MartinCostello.Costellobot 17 | { 18 | public partial class Program 19 | { 20 | // Expose the Program class for use with WebApplicationFactory 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubAppOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GitHubAppOptions 7 | { 8 | public string AppId { get; set; } = string.Empty; 9 | 10 | public string ClientId { get; set; } = string.Empty; 11 | 12 | public string Name { get; set; } = string.Empty; 13 | 14 | public string? Organization { get; set; } 15 | 16 | public string PrivateKey { get; set; } = string.Empty; 17 | } 18 | -------------------------------------------------------------------------------- /src/Costellobot/SiteOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class SiteOptions 7 | { 8 | public IList AdminRoles { get; set; } = []; 9 | 10 | public IList AdminUsers { get; set; } = []; 11 | 12 | public IList CrawlerPaths { get; set; } = []; 13 | 14 | public string LogsUrl { get; set; } = string.Empty; 15 | 16 | public string TelemetryCollectorUrl { get; set; } = string.Empty; 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/LabelBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class LabelBuilder(string? name = null) : ResponseBuilder 7 | { 8 | public string Name { get; set; } = name ?? RandomString(); 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | id = Id, 15 | name = Name, 16 | @default = false, 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/IWorkflowRunsClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public interface IWorkflowRunsClient 7 | { 8 | Task> GetPendingDeploymentsAsync( 9 | string owner, 10 | string name, 11 | long runId); 12 | 13 | Task ReviewCustomProtectionRuleAsync( 14 | string deploymentCallbackUrl, 15 | ReviewDeploymentProtectionRule review, 16 | CancellationToken cancellationToken); 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CheckRunsResponseBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CheckRunsResponseBuilder : ResponseBuilder 7 | { 8 | public IList CheckRuns { get; } = []; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | check_runs = CheckRuns.Build(), 15 | total_count = CheckRuns.Count, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot/IGitHubClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit; 5 | using IConnection = Octokit.GraphQL.IConnection; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public interface IGitHubClientFactory 10 | { 11 | IGitHubClientForApp CreateForApp(string appId); 12 | 13 | IGitHubClientForInstallation CreateForInstallation(string installationId); 14 | 15 | IConnection CreateForGraphQL(string installationId); 16 | 17 | IGitHubClientForUser CreateForUser(); 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot/TrustedEntitiesOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class TrustedEntitiesOptions 7 | { 8 | public IList Dependencies { get; set; } = []; 9 | 10 | public IDictionary> Publishers { get; set; } = new Dictionary>(); 11 | 12 | public IList Reviewers { get; set; } = []; 13 | 14 | public IList Users { get; set; } = []; 15 | } 16 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CheckSuitesResponseBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CheckSuitesResponseBuilder : ResponseBuilder 7 | { 8 | public IList CheckSuites { get; } = []; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | check_suites = CheckSuites.Build(), 15 | total_count = CheckSuites.Count, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot/vitest.config.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | clearMocks: true, 9 | coverage: { 10 | enabled: true, 11 | provider: 'v8', 12 | reporter: ['html', 'lcov', 'text'], 13 | include: ['scripts/**/*.ts'], 14 | exclude: ['scripts/**/*.test.ts'], 15 | }, 16 | reporters: ['default', 'github-actions'], 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/WorkflowRunsResponseBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class WorkflowRunsResponseBuilder : ResponseBuilder 7 | { 8 | public IList WorkflowRuns { get; } = []; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | total_count = WorkflowRuns.Count, 15 | workflow_runs = WorkflowRuns.Build(), 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot.AppHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "AppHost": { 5 | "commandName": "Project", 6 | "applicationUrl": "https://localhost:15288;http://localhost:15289", 7 | "dotnetRunMessages": true, 8 | "launchBrowser": true, 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16099", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037" 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Handlers/NullHandlerTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Handlers; 5 | 6 | public static class NullHandlerTests 7 | { 8 | [Fact] 9 | public static async Task Null_Handler_Does_Nothing() 10 | { 11 | // Arrange 12 | var target = NullHandler.Instance; 13 | 14 | // Act 15 | await Should.NotThrowAsync(() => target.HandleAsync(new Octokit.Webhooks.Events.PingEvent(), TestContext.Current.CancellationToken)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Costellobot/wwwroot/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "costellobot", 3 | "short_name": "costellobot", 4 | "description": "costellobot is an application that provides GitHub automation for Martin Costello's repositories.", 5 | "id": "/", 6 | "start_url": "/", 7 | "background_color": "#ffffff", 8 | "theme_color": "#183153", 9 | "display": "minimal-ui", 10 | "lang": "en", 11 | "dir": "ltr", 12 | "icons": [ 13 | { 14 | "src": "https://cdn.martincostello.com/favicon-costellobot.png", 15 | "sizes": "64x64", 16 | "type": "image/png", 17 | "purpose": "any" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Pages/ConfigurationPage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Playwright; 5 | 6 | namespace MartinCostello.Costellobot.Pages; 7 | 8 | public sealed class ConfigurationPage(IPage page) : AppPage(page) 9 | { 10 | public async Task WaitForContentAsync() 11 | => await Page.WaitForSelectorAsync(Selectors.ConfigurationContent); 12 | 13 | private sealed class Selectors 14 | { 15 | internal const string ConfigurationContent = "id=configuration-content"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Costellobot/IWebhookClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using Microsoft.AspNetCore.SignalR; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public interface IWebhookClient 10 | { 11 | [HubMethodName("application-logs")] 12 | Task LogAsync(ClientLogMessage logEntry); 13 | 14 | [HubMethodName("webhook-logs")] 15 | Task WebhookAsync( 16 | IDictionary headers, 17 | JsonElement webhookEvent, 18 | CancellationToken cancellationToken); 19 | } 20 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/GitDiffParserTests.npm.None.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/Costellobot/package.json b/src/Costellobot/package.json 2 | index 71128e806..97af62463 100644 3 | --- a/src/Costellobot/package.json 4 | +++ b/src/Costellobot/package.json 5 | @@ -66,11 +66,11 @@ 6 | "jest" 7 | ], 8 | "rules": { 9 | + "@stylistic/js/indent": "error", 10 | "@stylistic/js/quotes": [ 11 | "error", 12 | "single" 13 | ], 14 | - "@typescript-eslint/indent": "error", 15 | "@typescript-eslint/member-delimiter-style": "error", 16 | "@typescript-eslint/naming-convention": "error", 17 | "@typescript-eslint/prefer-namespace-keyword": "error", 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/WorkflowRunBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class WorkflowRunBuilder(RepositoryBuilder repository) : ResponseBuilder 7 | { 8 | public string Name { get; set; } = RandomString(); 9 | 10 | public RepositoryBuilder Repository { get; set; } = repository; 11 | 12 | public override object Build() 13 | { 14 | return new 15 | { 16 | id = Id, 17 | name = Name, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Costellobot/GoogleOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GoogleOptions 7 | { 8 | public IList CalendarIds { get; set; } = []; 9 | 10 | public string ClientEmail { get; set; } = string.Empty; 11 | 12 | public string PrivateKeyId { get; set; } = string.Empty; 13 | 14 | public string PrivateKey { get; set; } = string.Empty; 15 | 16 | public string ProjectId { get; set; } = string.Empty; 17 | 18 | public IList Scopes { get; set; } = []; 19 | } 20 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/CategoryAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Xunit.v3; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] 9 | public sealed class CategoryAttribute(string category) : Attribute, ITraitAttribute 10 | { 11 | public string Category { get; } = category; 12 | 13 | public IReadOnlyCollection> GetTraits() 14 | => [new("Category", Category)]; 15 | } 16 | -------------------------------------------------------------------------------- /src/Costellobot/Registries/PackageRegistry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Registries; 5 | 6 | public abstract class PackageRegistry(HttpClient client) : IPackageRegistry 7 | { 8 | public abstract DependencyEcosystem Ecosystem { get; } 9 | 10 | protected HttpClient Client { get; } = client; 11 | 12 | public abstract Task> GetPackageOwnersAsync( 13 | RepositoryId repository, 14 | string id, 15 | string version, 16 | CancellationToken cancellationToken); 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/AccessTokenBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class AccessTokenBuilder : ResponseBuilder 7 | { 8 | public string Token { get; set; } = "ghs_" + RandomString(); 9 | 10 | public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(1); 11 | 12 | public override object Build() 13 | { 14 | return new 15 | { 16 | token = Token, 17 | expires_at = ExpiresAt, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/GitHubClientAdapter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public sealed class GitHubClientAdapter : GitHubClient, IGitHubClientForApp, IGitHubClientForInstallation, IGitHubClientForUser 7 | { 8 | public GitHubClientAdapter(ProductHeaderValue productInformation, ICredentialStore credentialStore, Uri baseAddress) 9 | : base(productInformation, credentialStore, baseAddress) 10 | { 11 | } 12 | 13 | public GitHubClientAdapter(IConnection connection) 14 | : base(connection) 15 | { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/BrowserFixtureOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Infrastructure; 5 | 6 | public class BrowserFixtureOptions 7 | { 8 | public string BrowserType { get; set; } = Microsoft.Playwright.BrowserType.Chromium; 9 | 10 | public string? BrowserChannel { get; set; } 11 | 12 | public bool CaptureTrace { get; set; } = BrowserFixture.IsRunningInGitHubActions; 13 | 14 | public bool CaptureVideo { get; set; } = BrowserFixture.IsRunningInGitHubActions; 15 | 16 | public string? TestName { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dotnet 3 | .idea 4 | .metadata 5 | .pyroscope 6 | .settings 7 | .vs 8 | _ReSharper* 9 | _reports 10 | _UpgradeReport_Files/ 11 | artifacts/ 12 | Backup*/ 13 | BenchmarkDotNet.Artifacts/ 14 | bin 15 | Bin 16 | build/ 17 | coverage 18 | coverage.* 19 | junit.xml 20 | node_modules 21 | obj 22 | packages 23 | project.lock.json 24 | src/Costellobot/wwwroot/static/ 25 | TestResults 26 | typings 27 | UpgradeLog*.htm 28 | UpgradeLog*.XML 29 | PublishProfiles 30 | *.db 31 | *.db-shm 32 | *.db-wal 33 | *.DotSettings 34 | *.GhostDoc.xml 35 | *.log 36 | *.nupkg 37 | *.opensdf 38 | *.[Pp]ublish.xml 39 | *.publishproj 40 | *.pubxml 41 | *.sdf 42 | *.sln.cache 43 | *.sln.docstates 44 | *.sln.ide 45 | *.suo 46 | *.user 47 | !.packages/*.nupkg 48 | -------------------------------------------------------------------------------- /tests/Costellobot.EndToEndTests/EndToEndTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | [Category("EndToEnd")] 7 | [Collection] 8 | public abstract class EndToEndTest(AppFixture fixture, ITestOutputHelper outputHelper) 9 | { 10 | protected virtual CancellationToken CancellationToken => TestContext.Current.CancellationToken; 11 | 12 | protected AppFixture Fixture { get; } = fixture; 13 | 14 | protected ITestOutputHelper OutputHelper { get; } = outputHelper; 15 | 16 | protected Uri ServerAddress => Fixture.ServerAddress!; 17 | } 18 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.VisualStudio.Component.CoreEditor", 5 | "Microsoft.VisualStudio.Workload.CoreEditor", 6 | "Microsoft.NetCore.Component.Runtime.10.0", 7 | "Microsoft.NetCore.Component.SDK", 8 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 9 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", 10 | "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", 11 | "Microsoft.VisualStudio.Component.JavaScript.TypeScript", 12 | "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", 13 | "Component.Microsoft.VisualStudio.RazorExtension", 14 | "Microsoft.VisualStudio.Component.Node.Tools", 15 | "Microsoft.VisualStudio.Workload.Node" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CommentBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CommentBuilder(string body, string authorAssociation = "OWNER") : ResponseBuilder 7 | { 8 | public string AuthorAssociation { get; set; } = authorAssociation; 9 | 10 | public string Body { get; set; } = body; 11 | 12 | public override object Build() 13 | { 14 | return new 15 | { 16 | id = Id, 17 | author_association = AuthorAssociation, 18 | body = Body, 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/InstallationRepositoriesBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class InstallationRepositoriesBuilder(IEnumerable repositories) : ResponseBuilder 7 | { 8 | public IList Repositories { get; } = [.. repositories]; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | total_count = Repositories.Count, 15 | repositories = Repositories.Select((p) => p.Build()).ToArray(), 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Costellobot/Models/ErrorModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Models; 5 | 6 | public sealed class ErrorModel(int statusCode) 7 | { 8 | public int ErrorStatusCode { get; } = statusCode; 9 | 10 | public bool IsClientError { get; set; } 11 | 12 | public string Message { get; set; } = "Sorry, something went wrong."; 13 | 14 | public string? RequestId { get; set; } 15 | 16 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 17 | 18 | public string Title { get; set; } = "Error"; 19 | 20 | public string Subtitle { get; set; } = "Error"; 21 | } 22 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/UserCredentialStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace Octokit; 8 | 9 | public sealed class UserCredentialStore(IOptionsMonitor options) : ICredentialStore 10 | { 11 | public Task GetCredentials() 12 | { 13 | var credentials = Credentials.Anonymous; 14 | 15 | if (options.CurrentValue.AccessToken is { Length: > 0 } token) 16 | { 17 | credentials = new(token); 18 | } 19 | 20 | return Task.FromResult(credentials); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | $(MSBuildThisFileDirectory)Costellobot.ruleset 5 | true 6 | true 7 | 1.0.$([MSBuild]::ValueOrDefault('$(GITHUB_RUN_NUMBER)', '0')) 8 | 9 | 10 | true 11 | $(NoWarn);419;1570;1573;1574;1584;1591;SA0001;SA1602 12 | 13 | 14 | -------------------------------------------------------------------------------- /startvs.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches a Visual Studio solution with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | SET sln=%~dp0Costellobot.slnx 14 | 15 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 16 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 17 | exit /b 1 18 | ) 19 | 20 | IF "%VSINSTALLDIR%" == "" ( 21 | start "" "%sln%" 22 | ) else ( 23 | "%VSINSTALLDIR%\Common7\IDE\devenv.com" "%sln%" 24 | ) 25 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/ResponseBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Cryptography; 5 | 6 | namespace MartinCostello.Costellobot.Builders; 7 | 8 | public abstract class ResponseBuilder 9 | { 10 | public int Id { get; set; } = RandomNumber(); 11 | 12 | public abstract object Build(); 13 | 14 | protected static int RandomNumber() => RandomNumberGenerator.GetInt32(int.MaxValue); 15 | 16 | protected static string RandomGitSha() => Guid.NewGuid().ToString().Replace("-", string.Empty, StringComparison.Ordinal); 17 | 18 | protected static string RandomString() => Guid.NewGuid().ToString(); 19 | } 20 | -------------------------------------------------------------------------------- /src/Costellobot/HttpRequestExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Primitives; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public static class HttpRequestExtensions 9 | { 10 | public static bool IsJson(this HttpRequest request) 11 | { 12 | var headers = request.GetTypedHeaders(); 13 | 14 | return IsJson(headers.ContentType?.MediaType ?? StringSegment.Empty) || headers.Accept.Any((p) => IsJson(p.MediaType)); 15 | 16 | static bool IsJson(StringSegment? segment) 17 | => segment?.Equals("application/json", StringComparison.OrdinalIgnoreCase) is true; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/CredentialStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Caching.Hybrid; 5 | 6 | namespace Octokit; 7 | 8 | public abstract class CredentialStore : ICredentialStore 9 | { 10 | protected static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(10); 11 | protected static readonly TimeSpan TokenSkew = TimeSpan.FromMinutes(1); 12 | 13 | protected static readonly HybridCacheEntryOptions CacheEntryOptions = new() { Expiration = TokenLifetime - TokenSkew }; 14 | protected static readonly string[] CacheTags = ["all", "github"]; 15 | 16 | public abstract Task GetCredentials(); 17 | } 18 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/RemoteAuthorizationEventsFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using AspNet.Security.OAuth.GitHub; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace MartinCostello.Costellobot.Infrastructure; 8 | 9 | public sealed class RemoteAuthorizationEventsFilter(IHttpClientFactory httpClientFactory) : IPostConfigureOptions 10 | { 11 | public void PostConfigure(string? name, GitHubAuthenticationOptions options) 12 | { 13 | options.Backchannel = httpClientFactory.CreateClient(name ?? string.Empty); 14 | options.EventsType = typeof(LoopbackOAuthEvents); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Costellobot/ClientLogMessage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class ClientLogMessage 7 | { 8 | public string Category { get; init; } = string.Empty; 9 | 10 | public string Level { get; init; } = string.Empty; 11 | 12 | public int EventId { get; init; } 13 | 14 | public string? EventName { get; init; } 15 | 16 | public string Message { get; init; } = string.Empty; 17 | 18 | public DateTimeOffset Timestamp { get; init; } 19 | 20 | public string Exception { get; init; } = string.Empty; 21 | 22 | public Dictionary Properties { get; } = []; 23 | } 24 | -------------------------------------------------------------------------------- /perf/Costellobot.Benchmarks/Costellobot.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Benchmarks for Costellobot. 4 | Exe 5 | MartinCostello.Costellobot.Benchmarks 6 | net10.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/DeploymentBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class DeploymentBuilder : ResponseBuilder 7 | { 8 | public string Environment { get; set; } = RandomString(); 9 | 10 | public string Sha { get; set; } = RandomGitSha(); 11 | 12 | public PendingDeploymentBuilder CreatePendingDeployment() 13 | => new() { Environment = Environment }; 14 | 15 | public override object Build() 16 | { 17 | return new 18 | { 19 | id = Id, 20 | environment = Environment, 21 | sha = Sha, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/HttpRequestInterceptionFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using Microsoft.Extensions.Http; 6 | 7 | namespace MartinCostello.Costellobot.Infrastructure; 8 | 9 | public sealed class HttpRequestInterceptionFilter(HttpClientInterceptorOptions options) : IHttpMessageHandlerBuilderFilter 10 | { 11 | public Action Configure(Action next) 12 | { 13 | return (builder) => 14 | { 15 | next(builder); 16 | builder.AdditionalHandlers.Add(options.CreateHttpMessageHandler()); 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /startvscode.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | :: This command launches Visual Studio Code with environment variables required to use a local version of the .NET SDK. 5 | 6 | :: This tells .NET to use the same dotnet.exe that the build script uses. 7 | SET DOTNET_ROOT=%~dp0.dotnet 8 | SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 9 | 10 | :: Put our local dotnet.exe on PATH first so Visual Studio Code knows which one to use. 11 | SET PATH=%DOTNET_ROOT%;%PATH% 12 | 13 | :: Sets the Target Framework for Visual Studio Code. 14 | SET TARGET=net10.0 15 | 16 | SET FOLDER=%~1 17 | 18 | IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( 19 | echo The .NET SDK has not yet been installed. Run `%~dp0build.ps1` to install it 20 | exit /b 1 21 | ) 22 | 23 | IF "%FOLDER%"=="" ( 24 | code . 25 | ) else ( 26 | code "%FOLDER%" 27 | ) 28 | 29 | exit /b 1 30 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubEvent.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using Octokit.Webhooks; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public record GitHubEvent( 10 | WebhookHeaders Headers, 11 | WebhookEvent Event, 12 | IDictionary RawHeaders, 13 | JsonElement RawPayload) 14 | { 15 | public override string ToString() 16 | { 17 | var builder = new StringBuilder() 18 | .Append(Headers.Event); 19 | 20 | if (Event.Action is { Length: > 0 }) 21 | { 22 | builder.Append(':') 23 | .Append(Event.Action); 24 | } 25 | 26 | return builder.ToString(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CompareResultBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CompareResultBuilder : ResponseBuilder 7 | { 8 | public IList Commits { get; set; } = []; 9 | 10 | public string Status { get; set; } = "ahead"; 11 | 12 | public int AheadBy { get; set; } = 1; 13 | 14 | public int BehindBy { get; set; } 15 | 16 | public override object Build() 17 | { 18 | return new 19 | { 20 | commits = Commits.Build(), 21 | status = Status, 22 | ahead_by = AheadBy, 23 | behind_by = BehindBy, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Costellobot.AppHost/Costellobot.AppHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | Exe 6 | net10.0 7 | Costellobot-AppHost 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "C# (.NET)", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "dbaeumer.vscode-eslint", 10 | "EditorConfig.EditorConfig", 11 | "k--kato.docomment", 12 | "ms-dotnettools.csharp", 13 | "ms-vscode.PowerShell" 14 | ] 15 | } 16 | }, 17 | "forwardPorts": [ 5000, 5001 ], 18 | "portsAttributes":{ 19 | "5000": { 20 | "onAutoForward": "silent" 21 | }, 22 | "5001": { 23 | "label": "Costellobot", 24 | "onAutoForward": "openBrowserOnce" 25 | } 26 | }, 27 | "postCreateCommand": "./build.ps1 -SkipTests", 28 | "remoteEnv": { 29 | "DOTNET_ROLL_FORWARD": "Major", 30 | "PATH": "/root/.dotnet/tools:${containerWorkspaceFolder}/.dotnet:${containerEnv:PATH}" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /perf/Costellobot.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using BenchmarkDotNet.Running; 5 | using MartinCostello.Costellobot.Benchmarks; 6 | 7 | if (args.SequenceEqual(["--test"])) 8 | { 9 | await using var benchmark = new AppBenchmarks(); 10 | await benchmark.StartServer(); 11 | 12 | try 13 | { 14 | _ = await benchmark.Root(); 15 | _ = await benchmark.Version(); 16 | _ = await benchmark.Webhook(); 17 | } 18 | finally 19 | { 20 | await benchmark.StopServer(); 21 | } 22 | 23 | return 0; 24 | } 25 | else 26 | { 27 | var summary = BenchmarkRunner.Run(args: args); 28 | return summary.Reports.Any((p) => !p.Success) ? 1 : 0; 29 | } 30 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubWebhookHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.SignalR; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | [Authorization.CostellobotAdmin] 9 | public class GitHubWebhookHub(ClientLogQueue logs, GitHubWebhookQueue webhooks) : Hub 10 | { 11 | public override async Task OnConnectedAsync() 12 | { 13 | foreach (var logEntry in logs.History()) 14 | { 15 | await Clients.Caller.LogAsync(logEntry); 16 | } 17 | 18 | foreach (var @event in webhooks.History()) 19 | { 20 | await Clients.Caller.WebhookAsync(@event.RawHeaders, @event.RawPayload, Context.ConnectionAborted); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Costellobot/ClientLogBroadcastService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.SignalR; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public sealed class ClientLogBroadcastService(ClientLogQueue queue, IHubContext context) : BackgroundService() 9 | { 10 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 11 | { 12 | while (!stoppingToken.IsCancellationRequested) 13 | { 14 | var logEntry = await queue.DequeueAsync(stoppingToken); 15 | 16 | if (logEntry is null) 17 | { 18 | break; 19 | } 20 | 21 | await context.Clients.All.LogAsync(logEntry); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Costellobot/ILoggingBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public static class ILoggingBuilderExtensions 7 | { 8 | public static ILoggingBuilder AddSignalR(this ILoggingBuilder builder) 9 | { 10 | builder.Services.AddSingleton(); 11 | return builder; 12 | } 13 | 14 | public static ILoggingBuilder AddTelemetry(this ILoggingBuilder builder) 15 | { 16 | return builder.AddOpenTelemetry((options) => 17 | { 18 | options.IncludeFormattedMessage = true; 19 | options.IncludeScopes = true; 20 | 21 | options.SetResourceBuilder(ApplicationTelemetry.ResourceBuilder); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Options; 5 | using NSubstitute; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public static class ObjectExtensions 10 | { 11 | public static IOptionsMonitor ToMonitor(this T options) 12 | where T : class 13 | { 14 | var monitor = Substitute.For>(); 15 | 16 | monitor.CurrentValue.Returns(options); 17 | 18 | return monitor; 19 | } 20 | 21 | public static IOptionsSnapshot ToSnapshot(this T options) 22 | where T : class 23 | { 24 | var snapshot = Substitute.For>(); 25 | 26 | snapshot.Value.Returns(options); 27 | 28 | return snapshot; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/AntiforgeryTokens.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace MartinCostello.Costellobot.Infrastructure; 7 | 8 | public class AntiforgeryTokens 9 | { 10 | [JsonPropertyName("cookieName")] 11 | public string CookieName { get; set; } = string.Empty; 12 | 13 | [JsonPropertyName("cookieValue")] 14 | public string? CookieValue { get; set; } = string.Empty; 15 | 16 | [JsonPropertyName("formFieldName")] 17 | public string? FormFieldName { get; set; } = string.Empty; 18 | 19 | [JsonPropertyName("headerName")] 20 | public string HeaderName { get; set; } = string.Empty; 21 | 22 | [JsonPropertyName("requestToken")] 23 | public string? RequestToken { get; set; } = string.Empty; 24 | } 25 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/GitDiffParserTests.NuGet.OneFile.diff: -------------------------------------------------------------------------------- 1 | diff --git a/Directory.Packages.props b/Directory.Packages.props 2 | index 5efad590..1641603a 100644 3 | --- a/Directory.Packages.props 4 | +++ b/Directory.Packages.props 5 | @@ -7,8 +7,8 @@ 6 | 7 | 8 | 9 | - 10 | - 11 | + 12 | + 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Costellobot/Models/Badge.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace MartinCostello.Costellobot.Models; 7 | 8 | public sealed class Badge 9 | { 10 | [JsonPropertyName("schemaVersion")] 11 | public required int SchemaVersion { get; init; } 12 | 13 | [JsonPropertyName("label")] 14 | public required string Label { get; init; } 15 | 16 | [JsonPropertyName("message")] 17 | public required string Message { get; init; } 18 | 19 | [JsonPropertyName("color")] 20 | public required string Color { get; init; } 21 | 22 | [JsonPropertyName("isError")] 23 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 24 | public bool IsError { get; set; } 25 | 26 | [JsonPropertyName("namedLogo")] 27 | public required string NamedLogo { get; init; } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Costellobot.EndToEndTests/Costellobot.EndToEndTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | MartinCostello.Costellobot 5 | net10.0 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/GitHubAppBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class GitHubAppBuilder(string slug, UserBuilder owner) : ResponseBuilder 7 | { 8 | public string ClientId { get; set; } = RandomString(); 9 | 10 | public string Name { get; set; } = RandomString(); 11 | 12 | public string NodeId { get; set; } = RandomString(); 13 | 14 | public UserBuilder Owner { get; set; } = owner; 15 | 16 | public string Slug { get; } = slug; 17 | 18 | public override object Build() 19 | { 20 | return new 21 | { 22 | client_id = ClientId, 23 | id = Id, 24 | name = Name, 25 | node_id = NodeId, 26 | owner = Owner.Build(), 27 | slug = Slug, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Costellobot/Registries/IPackageRegistry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Registries; 5 | 6 | public interface IPackageRegistry 7 | { 8 | private static readonly Task Null = Task.FromResult(null); 9 | 10 | DependencyEcosystem Ecosystem { get; } 11 | 12 | Task AreOwnersTrustedAsync(IReadOnlyList owners, CancellationToken cancellationToken) 13 | => Task.FromResult(false); 14 | 15 | Task GetPackageAttestationAsync( 16 | RepositoryId repository, 17 | string id, 18 | string version, 19 | CancellationToken cancellationToken) => Null; 20 | 21 | Task> GetPackageOwnersAsync( 22 | RepositoryId repository, 23 | string id, 24 | string version, 25 | CancellationToken cancellationToken); 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: dependency-review 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dotnet-vnext 8 | - dotnet-nightly 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | dependency-review: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | steps: 20 | 21 | - name: Checkout code 22 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 23 | with: 24 | filter: 'tree:0' 25 | persist-credentials: false 26 | show-progress: false 27 | 28 | - name: Review dependencies 29 | uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 30 | with: 31 | allow-dependencies-licenses: 'pkg:npm/protobufjs' 32 | allow-licenses: '(MIT AND BSD-3-Clause),(MIT AND Zlib),(MIT OR Apache-2.0),(MIT OR CC0-1.0),0BSD,Apache-2.0,BlueOak-1.0.0,BSD-2-Clause,BSD-3-Clause,CC-BY-4.0,CC0-1.0,ISC,MIT,MIT-0,Python-2.0,Unlicense' 33 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/UserBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class UserBuilder(string? login) : ResponseBuilder 7 | { 8 | public string Login { get; set; } = login ?? RandomString(); 9 | 10 | public string UserType { get; set; } = "user"; 11 | 12 | public RepositoryBuilder CreateRepository(string? name = null, bool isFork = false, bool isPrivate = false) 13 | { 14 | return new(this, name) 15 | { 16 | IsFork = isFork, 17 | IsPrivate = isPrivate, 18 | }; 19 | } 20 | 21 | public override object Build() 22 | { 23 | return new 24 | { 25 | avatar_url = $"https://avatars.githubusercontent.com/u/{Id}?v=4", 26 | id = Id, 27 | login = Login, 28 | type = UserType, 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit; 5 | using IConnection = Octokit.GraphQL.IConnection; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public sealed class GitHubClientFactory(IServiceProvider serviceProvider) : IGitHubClientFactory 10 | { 11 | public IGitHubClientForApp CreateForApp(string appId) 12 | => serviceProvider.GetRequiredKeyedService(appId); 13 | 14 | public IGitHubClientForInstallation CreateForInstallation(string installationId) 15 | => serviceProvider.GetRequiredKeyedService(installationId); 16 | 17 | public IConnection CreateForGraphQL(string installationId) 18 | => serviceProvider.GetRequiredKeyedService(installationId); 19 | 20 | public IGitHubClientForUser CreateForUser() 21 | => serviceProvider.GetRequiredService(); 22 | } 23 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/AdministratorHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace MartinCostello.Costellobot.Authorization; 8 | 9 | /// 10 | /// A class representing an authorization handler for site administrators. 11 | /// 12 | /// The to use. 13 | public sealed partial class AdministratorHandler(IOptionsMonitor options) : AuthorizationHandler 14 | { 15 | /// 16 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AdministratorRequirement requirement) 17 | { 18 | if (context.User.IsAdministrator(options.CurrentValue)) 19 | { 20 | context.Succeed(requirement); 21 | } 22 | 23 | return Task.CompletedTask; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/HealthOperatorHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace MartinCostello.Costellobot.Authorization; 8 | 9 | /// 10 | /// A class representing an authorization handler for automated health probe operators. 11 | /// 12 | /// The to use. 13 | public sealed partial class HealthOperatorHandler(IOptionsMonitor options) : AuthorizationHandler 14 | { 15 | /// 16 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HealthProbeRequirement requirement) 17 | { 18 | if (context.User.IsAdministrator(options.CurrentValue)) 19 | { 20 | context.Succeed(requirement); 21 | } 22 | 23 | return Task.CompletedTask; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/PendingDeploymentBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class PendingDeploymentBuilder : ResponseBuilder 7 | { 8 | public string Environment { get; set; } = "production"; 9 | 10 | public override object Build() 11 | { 12 | return new 13 | { 14 | environment = new 15 | { 16 | name = Environment, 17 | }, 18 | wait_timer = 0, 19 | wait_timer_started_at = new long?(), 20 | current_user_can_approve = false, 21 | reviewers = new[] 22 | { 23 | new 24 | { 25 | type = "User", 26 | reviewer = new 27 | { 28 | login = "repo-owner", 29 | }, 30 | }, 31 | }, 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Costellobot/Slices/SignIn.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSlice 2 | @implements IUsesLayout<_Layout, LayoutModel> 3 | 4 | @if (string.Equals(HttpContext!.Request.Query["denied"], bool.TrueString, StringComparison.OrdinalIgnoreCase)) 5 | { 6 | 17 | } 18 | 19 |

20 | Sign in with your GitHub account to access costellobot. 21 |

22 | 23 |
24 | 25 | 26 |
27 | 28 | @functions { 29 | public LayoutModel LayoutModel { get; } = new("Sign In"); 30 | } 31 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CheckRunBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CheckRunBuilder(string status, string? conclusion) : ResponseBuilder 7 | { 8 | public string ApplicationName { get; set; } = "GitHub Actions"; 9 | 10 | public string Name { get; set; } = RandomString(); 11 | 12 | public string? Conclusion { get; set; } = conclusion; 13 | 14 | public IList PullRequests { get; set; } = []; 15 | 16 | public string Status { get; set; } = status; 17 | 18 | public override object Build() 19 | { 20 | return new 21 | { 22 | id = Id, 23 | name = Name, 24 | conclusion = Conclusion, 25 | pull_requests = PullRequests.Build(), 26 | status = Status, 27 | app = new 28 | { 29 | name = ApplicationName, 30 | }, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/GitHubCommitBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class GitHubCommitBuilder(RepositoryBuilder repository) : ResponseBuilder 7 | { 8 | public UserBuilder? Author { get; set; } 9 | 10 | public UserBuilder? Committer { get; set; } 11 | 12 | public string Message { get; set; } = RandomString(); 13 | 14 | public IList Parents { get; set; } = []; 15 | 16 | public RepositoryBuilder Repository { get; set; } = repository; 17 | 18 | public string Sha { get; set; } = RandomGitSha(); 19 | 20 | public override object Build() 21 | { 22 | return new 23 | { 24 | author = (Author ?? Repository.Owner).Build(), 25 | commit = new GitCommitBuilder(Author ?? Repository.Owner) { Message = Message }.Build(), 26 | parents = Parents.Select((p) => new { sha = p }).ToArray(), 27 | sha = Sha, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Costellobot/ITrustStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Models; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public interface ITrustStore 9 | { 10 | Task DistrustAsync( 11 | DependencyEcosystem ecosystem, 12 | string id, 13 | string version, 14 | CancellationToken cancellationToken = default); 15 | 16 | Task DistrustAllAsync(CancellationToken cancellationToken = default); 17 | 18 | Task> GetTrustAsync( 19 | DependencyEcosystem ecosystem, 20 | CancellationToken cancellationToken = default); 21 | 22 | Task IsTrustedAsync( 23 | DependencyEcosystem ecosystem, 24 | string id, 25 | string version, 26 | CancellationToken cancellationToken = default); 27 | 28 | Task TrustAsync( 29 | DependencyEcosystem ecosystem, 30 | string id, 31 | string version, 32 | CancellationToken cancellationToken = default); 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch Costellobot", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceFolder}/src/Costellobot/bin/Debug/net10.0/Costellobot.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/src/Costellobot", 12 | "stopAtEntry": false, 13 | "serverReadyAction": { 14 | "action": "openExternally", 15 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 16 | }, 17 | "env": { 18 | "ASPNETCORE_ENVIRONMENT": "Development", 19 | "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "${env:CODESPACES}" 20 | } 21 | }, 22 | { 23 | "name": "Run tests", 24 | "type": "coreclr", 25 | "request": "launch", 26 | "preLaunchTask": "build", 27 | "program": "dotnet", 28 | "args": [ 29 | "test" 30 | ], 31 | "cwd": "${workspaceFolder}/tests/Costellobot.Tests", 32 | "console": "internalConsole", 33 | "stopAtEntry": false, 34 | "internalConsoleOptions": "openOnSessionStart" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/ShouldlyTaskExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Shouldly; 5 | 6 | public static class ShouldlyTaskExtensions 7 | { 8 | public static async Task ShouldBe(this Task task, string expected) 9 | { 10 | string actual = await task; 11 | actual.ShouldBe(expected); 12 | } 13 | 14 | public static async Task ShouldBe(this Task task, T expected) 15 | { 16 | T actual = await task; 17 | actual.ShouldBe(expected); 18 | } 19 | 20 | public static async Task ShouldBeFalse(this Task task) 21 | { 22 | bool actual = await task; 23 | actual.ShouldBeFalse(); 24 | } 25 | 26 | public static async Task ShouldBeTrue(this Task task) 27 | { 28 | bool actual = await task; 29 | actual.ShouldBeTrue(); 30 | } 31 | 32 | public static async Task ShouldNotBeNullOrWhiteSpace(this Task task) 33 | { 34 | string actual = await task; 35 | actual.ShouldNotBeNullOrWhiteSpace(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Suggest a feature request or improvement 3 | title: '[Feature request]: ' 4 | labels: ['feature-request'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/costellobot/blob/main/README.md) before submitting a feature request. 10 | - type: textarea 11 | attributes: 12 | label: Is your feature request related to a specific problem? Or an existing feature? 13 | description: A clear and concise description of what the problem is. Motivating examples help prioritise things. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the solution you'd like 20 | description: | 21 | A clear and concise description of what you want to happen. Include any alternative solutions you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: | 28 | Add any other context or screenshots about the feature request here. 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Bundles/oauth-http-bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/main/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", 3 | "id": "oauth-http-bundle", 4 | "version": 1, 5 | "comment": "HTTP bundle for GitHub OAuth authentication.", 6 | "items": [ 7 | { 8 | "comment": "Token resource for GitHub login", 9 | "uri": "https://github.com/login/oauth/access_token", 10 | "method": "POST", 11 | "contentFormat": "json", 12 | "contentJson": { 13 | "access_token": "gho_secret-access-token", 14 | "token_type": "bearer", 15 | "scope": "" 16 | } 17 | }, 18 | { 19 | "comment": "User information resource for GitHub login", 20 | "uri": "https://api.github.com/user", 21 | "contentFormat": "json", 22 | "contentJson": { 23 | "login": "john-smith", 24 | "id": 1, 25 | "type": "User", 26 | "name": "John Smith", 27 | "company": "GitHub", 28 | "location": "London, UK", 29 | "html_url": "https://github.com/john-smith", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/9141961?s=60&v=4" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/GitCommitBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class GitCommitBuilder(UserBuilder author) : ResponseBuilder 7 | { 8 | public IList Added { get; set; } = []; 9 | 10 | public UserBuilder Author { get; set; } = author; 11 | 12 | public UserBuilder? Committer { get; set; } 13 | 14 | public string TreeId { get; set; } = RandomString(); 15 | 16 | public string Message { get; set; } = RandomString(); 17 | 18 | public IList Modified { get; set; } = []; 19 | 20 | public IList Removed { get; set; } = []; 21 | 22 | public override object Build() 23 | { 24 | return new 25 | { 26 | id = Id.ToString(CultureInfo.InvariantCulture), 27 | tree_id = TreeId, 28 | message = Message, 29 | author = Author.Build(), 30 | committer = (Committer ?? Author).Build(), 31 | added = Added, 32 | removed = Removed, 33 | modified = Modified, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/PullRequestReviewBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class PullRequestReviewBuilder( 7 | PullRequestBuilder pullRequest, 8 | UserBuilder user) : ResponseBuilder 9 | { 10 | public string AuthorAssociation { get; set; } = "OWNER"; 11 | 12 | public string Body { get; set; } = "This looks great."; 13 | 14 | public string NodeId { get; set; } = RandomString(); 15 | 16 | public PullRequestBuilder PullRequest { get; } = pullRequest; 17 | 18 | public string State { get; set; } = "commented"; 19 | 20 | public UserBuilder User { get; set; } = user; 21 | 22 | public override object Build() 23 | { 24 | return new 25 | { 26 | author_association = AuthorAssociation, 27 | body = Body, 28 | commit_id = PullRequest.RefHead, 29 | id = Id, 30 | node_id = NodeId, 31 | pull_request_url = PullRequest.Url, 32 | state = State, 33 | user = User.Build(), 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class GitHubOptions 7 | { 8 | public string AccessToken { get; set; } = string.Empty; 9 | 10 | public Dictionary Apps { get; set; } = []; 11 | 12 | public string BadgesKey { get; set; } = string.Empty; 13 | 14 | public string ClientId { get; set; } = string.Empty; 15 | 16 | public string ClientSecret { get; set; } = string.Empty; 17 | 18 | public string EnterpriseDomain { get; set; } = string.Empty; 19 | 20 | public Dictionary Installations { get; set; } = []; 21 | 22 | public string OAuthId { get; set; } = string.Empty; 23 | 24 | public IList Scopes { get; set; } = []; 25 | 26 | public string WebhookSecret { get; set; } = string.Empty; 27 | 28 | public string? TryGetAppId(string name) => 29 | Apps.Values.Where((p) => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) 30 | .Select((p) => p.AppId) 31 | .FirstOrDefault(); 32 | } 33 | -------------------------------------------------------------------------------- /src/Costellobot/RepositoryId.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | /// 7 | /// A record representing a GitHub repository ID. 8 | /// 9 | /// The login of the owner of the repository. 10 | /// The name of the repository. 11 | public sealed record RepositoryId(string Owner, string Name) 12 | { 13 | /// 14 | /// Gets the full name of the repository. 15 | /// 16 | public string FullName => $"{Owner}/{Name}"; 17 | 18 | /// 19 | /// Creates a new instance of for the specified repository. 20 | /// 21 | /// The GitHub repository. 22 | /// 23 | /// The associated with . 24 | /// 25 | public static RepositoryId Create(Octokit.Webhooks.Models.Repository repository) 26 | => new(repository.Owner.Login, repository.Name); 27 | 28 | /// 29 | public override string ToString() => FullName; 30 | } 31 | -------------------------------------------------------------------------------- /src/Costellobot/DeploymentRules/DeploymentRule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.DeploymentRules; 7 | 8 | /// 9 | /// Defines a rule that can be used to determine whether a deployment should proceed based on a GitHub webhook event. 10 | /// 11 | public abstract class DeploymentRule : IDeploymentRule 12 | { 13 | /// 14 | public abstract string Name { get; } 15 | 16 | public static async Task<(bool Approved, string? DeniedRuleName)> EvaluateAsync( 17 | IEnumerable rules, 18 | WebhookEvent message, 19 | CancellationToken cancellationToken) 20 | { 21 | foreach (var rule in rules.Where((p) => p.IsEnabled)) 22 | { 23 | if (!await rule.EvaluateAsync(message, cancellationToken)) 24 | { 25 | return (false, rule.Name); 26 | } 27 | } 28 | 29 | return (true, null); 30 | } 31 | 32 | /// 33 | public abstract Task EvaluateAsync(WebhookEvent message, CancellationToken cancellationToken); 34 | } 35 | -------------------------------------------------------------------------------- /src/Costellobot/CostellobotMetrics.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics.Metrics; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public sealed class CostellobotMetrics : IDisposable 9 | { 10 | private readonly Meter _meter; 11 | private readonly Counter _webhookDeliveriesCounter; 12 | 13 | public CostellobotMetrics(IMeterFactory meterFactory) 14 | { 15 | _meter = meterFactory.Create(ApplicationTelemetry.ServiceName, ApplicationTelemetry.ServiceVersion); 16 | 17 | _webhookDeliveriesCounter = _meter.CreateCounter( 18 | "costellobot.github.webhook.delivery", 19 | unit: "{count}", 20 | description: "The number of GitHub webhook deliveries received."); 21 | } 22 | 23 | public void Dispose() => _meter?.Dispose(); 24 | 25 | public void WebhookDelivery(string? @event, string? targetId) 26 | => _webhookDeliveriesCounter.Add( 27 | 1, 28 | new KeyValuePair("github.webhook.event", @event), 29 | new KeyValuePair("github.webhook.hook.installation.target.id", targetId)); 30 | } 31 | -------------------------------------------------------------------------------- /src/Costellobot/ClientLoggingProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public sealed class ClientLoggingProvider(ClientLogQueue queue, TimeProvider timeProvider) : ILoggerProvider, ISupportExternalScope 10 | { 11 | internal const string CategoryPrefix = "MartinCostello.Costellobot"; 12 | 13 | private readonly ConcurrentDictionary _loggers = []; 14 | private IExternalScopeProvider? _scopeProvider; 15 | 16 | public ILogger CreateLogger(string categoryName) 17 | { 18 | if (!categoryName.StartsWith(CategoryPrefix, StringComparison.Ordinal)) 19 | { 20 | return NullLogger.Instance; 21 | } 22 | 23 | return _loggers.GetOrAdd(categoryName, (name) => new(name, queue, _scopeProvider, timeProvider)); 24 | } 25 | 26 | public void Dispose() 27 | { 28 | // No-op 29 | } 30 | 31 | public void SetScopeProvider(IExternalScopeProvider scopeProvider) 32 | => _scopeProvider = scopeProvider; 33 | } 34 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/RepositoryDispatchDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public sealed class RepositoryDispatchDriver 11 | { 12 | public RepositoryDispatchDriver() 13 | { 14 | Owner = CreateUser(); 15 | Repository = Owner.CreateRepository(); 16 | } 17 | 18 | public string Branch { get; set; } = "main"; 19 | 20 | public object? ClientPayload { get; set; } 21 | 22 | public UserBuilder Owner { get; set; } 23 | 24 | public RepositoryBuilder Repository { get; set; } 25 | 26 | public object CreateWebhook(string action) 27 | { 28 | return new 29 | { 30 | action, 31 | branch = Branch, 32 | client_payload = ClientPayload, 33 | repository = Repository.Build(), 34 | sender = Owner.Build(), 35 | installation = new 36 | { 37 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 38 | }, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Costellobot/Slices/Home.cshtml: -------------------------------------------------------------------------------- 1 | @implements IUsesLayout<_Layout, LayoutModel> 2 | 3 |
4 |
5 |

6 | Application Logs 7 | 8 |

9 |
10 |
11 |
Awaiting logs...
12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 |
22 |
23 |

24 | Webhook Deliveries 25 | 26 | 27 |

28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 | @functions { 40 | public LayoutModel LayoutModel { get; } = new("Administration"); 41 | } 42 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/CheckSuiteBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class CheckSuiteBuilder(RepositoryBuilder repository, string status, string? conclusion) : ResponseBuilder 7 | { 8 | public string ApplicationName { get; set; } = "GitHub Actions"; 9 | 10 | public string ApplicationSlug { get; set; } = "github-actions"; 11 | 12 | public string? Conclusion { get; set; } = conclusion; 13 | 14 | public IList PullRequests { get; set; } = []; 15 | 16 | public RepositoryBuilder Repository { get; set; } = repository; 17 | 18 | public bool Rerequestable { get; set; } 19 | 20 | public string Status { get; set; } = status; 21 | 22 | public override object Build() 23 | { 24 | return new 25 | { 26 | id = Id, 27 | conclusion = Conclusion, 28 | pull_requests = PullRequests.Build(), 29 | rerequestable = Rerequestable, 30 | status = Status, 31 | app = new 32 | { 33 | name = ApplicationName, 34 | slug = ApplicationSlug, 35 | }, 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ossf-scorecard.yml: -------------------------------------------------------------------------------- 1 | name: ossf-scorecard 2 | 3 | on: 4 | branch_protection_rule: 5 | push: 6 | branches: [ main ] 7 | schedule: 8 | - cron: '0 5 * * MON' 9 | workflow_dispatch: 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: analysis 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | id-token: write 20 | security-events: write 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 25 | with: 26 | filter: 'tree:0' 27 | persist-credentials: false 28 | show-progress: false 29 | 30 | - name: Run analysis 31 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 32 | with: 33 | publish_results: true 34 | results_file: results.sarif 35 | results_format: sarif 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 39 | with: 40 | name: SARIF 41 | path: results.sarif 42 | retention-days: 5 43 | 44 | - name: Upload to code-scanning 45 | uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /src/Costellobot/Models/WebhookDelivery.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace MartinCostello.Costellobot.Models; 7 | 8 | public sealed class WebhookDelivery 9 | { 10 | [JsonPropertyName("id")] 11 | public long Id { get; set; } 12 | 13 | [JsonPropertyName("guid")] 14 | public string Guid { get; set; } = string.Empty; 15 | 16 | [JsonPropertyName("delivered_at")] 17 | public DateTimeOffset DeliveredAt { get; set; } 18 | 19 | [JsonPropertyName("redelivery")] 20 | public bool IsRedelivery { get; set; } 21 | 22 | [JsonPropertyName("duration")] 23 | public double Duration { get; set; } 24 | 25 | [JsonPropertyName("status")] 26 | public string Status { get; set; } = string.Empty; 27 | 28 | [JsonPropertyName("status_code")] 29 | public int StatusCode { get; set; } 30 | 31 | [JsonPropertyName("event")] 32 | public string Event { get; set; } = string.Empty; 33 | 34 | [JsonPropertyName("action")] 35 | public string? Action { get; set; } 36 | 37 | [JsonPropertyName("installation_id")] 38 | public long? InstallationId { get; set; } 39 | 40 | [JsonPropertyName("repository_id")] 41 | public long? RepositoryId { get; set; } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/IWebHostBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Infrastructure; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Hosting; 7 | 8 | namespace Microsoft.Extensions.DependencyInjection; 9 | 10 | public static class IWebHostBuilderExtensions 11 | { 12 | public static IWebHostBuilder ConfigureAntiforgeryTokenResource(this IWebHostBuilder builder) 13 | { 14 | return builder.ConfigureServices((services) => 15 | { 16 | services.AddTransient() 17 | .AddControllers() 18 | .AddApplicationPart(typeof(AntiforgeryTokenController).Assembly) 19 | .AddControllersAsServices(); 20 | }); 21 | } 22 | 23 | private sealed class AddMvcStartupFilter : IStartupFilter 24 | { 25 | public Action Configure(Action next) 26 | { 27 | return (builder) => 28 | { 29 | next(builder); 30 | builder.UseRouting(); 31 | builder.UseEndpoints((p) => p.MapControllers()); 32 | }; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Costellobot/DeploymentRules/IDeploymentRule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.DeploymentRules; 7 | 8 | /// 9 | /// Defines a rule that can be used to determine whether a deployment should proceed based on a GitHub webhook event. 10 | /// 11 | public interface IDeploymentRule 12 | { 13 | /// 14 | /// Gets a value indicating whether the rule is enabled. 15 | /// 16 | bool IsEnabled => true; 17 | 18 | /// 19 | /// Gets the name of the rule. 20 | /// 21 | string Name { get; } 22 | 23 | /// 24 | /// Evaluates the rule for the specified webhook event. 25 | /// 26 | /// The GitHub webhook payload. 27 | /// The to use. 28 | /// 29 | /// A that represents the asynchronous evaluation of the rule 30 | /// which returns if the rule is satisfied, otherwise . 31 | /// 32 | Task EvaluateAsync(WebhookEvent message, CancellationToken cancellationToken); 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/30_question.yml: -------------------------------------------------------------------------------- 1 | name: 🤔 Question? 2 | description: You have something specific to achieve and the existing documentation hasn't covered how. 3 | title: '[Question]: ' 4 | labels: ['question'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/costellobot/blob/main/README.md) before asking a question. 10 | - type: textarea 11 | attributes: 12 | label: What do you want to achieve? 13 | description: A clear and concise description of what you're trying to do. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: What code or approach do you have so far? 20 | description: | 21 | Provide a [minimalistic project which shows what you have so far](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 22 | Code snippets wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Add any other context or screenshots related to your question here. 30 | validations: 31 | required: false 32 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/ClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Claims; 5 | 6 | namespace MartinCostello.Costellobot.Authorization; 7 | 8 | public static class ClaimsPrincipalExtensions 9 | { 10 | public static bool IsAdministrator(this ClaimsPrincipal user, SiteOptions options) 11 | => user.IsAdministrator(options.AdminRoles, options.AdminUsers); 12 | 13 | public static bool IsAdministrator(this ClaimsPrincipal user, ICollection roles, ICollection users) 14 | { 15 | var userName = user.FindFirstValue(ClaimTypes.Name); 16 | 17 | if (string.IsNullOrEmpty(userName)) 18 | { 19 | return false; 20 | } 21 | 22 | bool hasClaim = false; 23 | bool needsClaim = false; 24 | 25 | if (users is { Count: > 0 }) 26 | { 27 | needsClaim = true; 28 | hasClaim = users.Contains(userName, StringComparer.Ordinal); 29 | } 30 | 31 | bool hasRole = false; 32 | bool needsRole = false; 33 | 34 | if (roles is { Count: > 0 }) 35 | { 36 | needsRole = true; 37 | hasRole = roles.Any(user.IsInRole); 38 | } 39 | 40 | return (!needsClaim || hasClaim) && (!needsRole || hasRole); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Costellobot/Handlers/HandlerFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.Handlers; 7 | 8 | public sealed class HandlerFactory(IServiceProvider serviceProvider) : IHandlerFactory 9 | { 10 | public IHandler Create(string? eventType) 11 | { 12 | return eventType switch 13 | { 14 | WebhookEventType.CheckSuite => serviceProvider.GetRequiredService(), 15 | WebhookEventType.DeploymentProtectionRule => serviceProvider.GetRequiredService(), 16 | WebhookEventType.DeploymentStatus => serviceProvider.GetRequiredService(), 17 | WebhookEventType.IssueComment => serviceProvider.GetRequiredService(), 18 | WebhookEventType.PullRequest => serviceProvider.GetRequiredService(), 19 | WebhookEventType.PullRequestReview => serviceProvider.GetRequiredService(), 20 | WebhookEventType.Push => serviceProvider.GetRequiredService(), 21 | WebhookEventType.RepositoryDispatch => serviceProvider.GetRequiredService(), 22 | _ => NullHandler.Instance, 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Costellobot/WebhookOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | public sealed class WebhookOptions 7 | { 8 | public bool Approve { get; set; } 9 | 10 | public string ApproveComment { get; set; } = string.Empty; 11 | 12 | public IList ApproveLabels { get; set; } = []; 13 | 14 | public bool Automerge { get; set; } 15 | 16 | public IList AutomergeLabels { get; set; } = []; 17 | 18 | public bool Deploy { get; set; } 19 | 20 | public string DeployComment { get; set; } = string.Empty; 21 | 22 | public IList DeployEnvironments { get; set; } = []; 23 | 24 | public bool Disable { get; set; } 25 | 26 | public IList IgnoreRepositories { get; set; } = []; 27 | 28 | public bool ImplicitTrust { get; set; } 29 | 30 | public TimeSpan PublishTimeout { get; set; } = TimeSpan.FromSeconds(5); 31 | 32 | public string QueueName { get; set; } = string.Empty; 33 | 34 | public IDictionary Registries { get; set; } = new Dictionary(); 35 | 36 | public IList RerunFailedChecks { get; set; } = []; 37 | 38 | public int RerunFailedChecksAttempts { get; set; } 39 | 40 | public TrustedEntitiesOptions TrustedEntities { get; set; } = new(); 41 | } 42 | -------------------------------------------------------------------------------- /src/Costellobot/DeploymentRules/ConfigurationDeploymentRule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Options; 5 | using Octokit.Webhooks; 6 | 7 | namespace MartinCostello.Costellobot.DeploymentRules; 8 | 9 | public sealed partial class ConfigurationDeploymentRule( 10 | IOptionsMonitor options, 11 | ILogger logger) : DeploymentRule 12 | { 13 | /// 14 | public override string Name => "Enabled-By-Application-Configuration"; 15 | 16 | /// 17 | public override Task EvaluateAsync(WebhookEvent message, CancellationToken cancellationToken) 18 | { 19 | var result = options.CurrentValue.Deploy; 20 | 21 | if (!result) 22 | { 23 | Log.DeploymentApprovalIsDisabled(logger); 24 | } 25 | 26 | return Task.FromResult(result); 27 | } 28 | 29 | [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] 30 | private static partial class Log 31 | { 32 | [LoggerMessage( 33 | EventId = 1, 34 | Level = LogLevel.Information, 35 | Message = "Deployment is not approved as it is disabled in application configuration.")] 36 | public static partial void DeploymentApprovalIsDisabled(ILogger logger); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Costellobot/RazorSliceExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Claims; 5 | using RazorSlices; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public static class RazorSliceExtensions 10 | { 11 | public static string? Content(this RazorSlice slice, string? contentPath, bool appendVersion = true) 12 | { 13 | string? result = string.Empty; 14 | 15 | if (!string.IsNullOrEmpty(contentPath)) 16 | { 17 | if (contentPath[0] == '~') 18 | { 19 | var segment = new PathString(contentPath[1..]); 20 | var applicationPath = slice.HttpContext!.Request.PathBase; 21 | 22 | var path = applicationPath.Add(segment); 23 | result = path.Value; 24 | } 25 | else 26 | { 27 | result = contentPath; 28 | } 29 | } 30 | 31 | if (appendVersion) 32 | { 33 | result += $"?v={GitMetadata.Commit}"; 34 | } 35 | 36 | return result; 37 | } 38 | 39 | public static string? RouteUrl(this RazorSlice slice, string? path) 40 | => Content(slice, path, appendVersion: false); 41 | 42 | public static ClaimsPrincipal User(this RazorSlice slice) 43 | => slice.HttpContext!.User; 44 | } 45 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Bundles/ruby-gems.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/main/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", 3 | "id": "nuget-search", 4 | "version": 1, 5 | "comment": "HTTP bundle for Ruby Gems.", 6 | "items": [ 7 | { 8 | "comment": "The owners of the rack gem.", 9 | "uri": "https://rubygems.org/api/v1/gems/rack/owners.json", 10 | "method": "GET", 11 | "contentFormat": "json", 12 | "contentJson": [ 13 | { 14 | "id": 31711, 15 | "handle": "chneukirchen" 16 | }, 17 | { 18 | "id": 264, 19 | "handle": "raggi" 20 | }, 21 | { 22 | "id": 207, 23 | "handle": "tenderlove" 24 | }, 25 | { 26 | "id": 96878, 27 | "handle": "eileencodes" 28 | }, 29 | { 30 | "id": 47349, 31 | "handle": "rafaelfranca" 32 | }, 33 | { 34 | "id": 44200, 35 | "handle": "ioquatix", 36 | "email": "samuel@ruby-lang.org" 37 | } 38 | ] 39 | }, 40 | { 41 | "comment": "A Ruby gem that does not exist", 42 | "uri": "https://rubygems.org/api/v1/gems/foo/owners.json", 43 | "method": "GET", 44 | "status": "404", 45 | "contentFormat": "string", 46 | "contentString": "This rubygem could not be found." 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | costellobot: 3 | image: ${DOCKER_REGISTRY-}costellobot:latest 4 | depends_on: 5 | - servicebus-emulator 6 | ports: 7 | - '8080:8080' 8 | environment: 9 | - ConnectionStrings__AzureServiceBus=Endpoint=sb://servicebus-emulator;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true; 10 | - ConnectionStrings__AzureTableStorage=UseDevelopmentStorage=true 11 | - DOTNET_ENVIRONMENT=Production 12 | - GitHub__ClientId=github-client-id 13 | - GitHub__ClientSecret=github-client-secret 14 | - OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318 15 | - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf 16 | - Webhook__QueueName=queue.1 17 | - WEBSITE_SITE_NAME=Costellobot 18 | servicebus-emulator: 19 | image: mcr.microsoft.com/azure-messaging/servicebus-emulator:1.1.2@sha256:353913ece3d9124cebd40f4b91d00dd197846b8cf86eae9a4790698709c64a1d 20 | depends_on: 21 | - sql-server 22 | ports: 23 | - '5300:5300' 24 | - '5672:5672' 25 | environment: 26 | - ACCEPT_EULA=Y 27 | - MSSQL_SA_PASSWORD=C0st3ll0b0t$ 28 | - SQL_SERVER=sql-server:1433 29 | sql-server: 30 | image: mcr.microsoft.com/mssql/server:2022-CU20-GDR1-ubuntu-22.04@sha256:6249e946034c0b72e98cbbdd790deede2bb94fe5c73335ecdbb77176af4d267c 31 | ports: 32 | - '1433:1433' 33 | environment: 34 | - ACCEPT_EULA=Y 35 | - MSSQL_SA_PASSWORD=C0st3ll0b0t$ 36 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/AntiforgeryTokenController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Antiforgery; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace MartinCostello.Costellobot.Infrastructure; 10 | 11 | public sealed class AntiforgeryTokenController : Controller 12 | { 13 | private const string GetUrl = "_testing/get-xsrf-token"; 14 | 15 | public static Uri GetTokensUri { get; } = new Uri(GetUrl, UriKind.Relative); 16 | 17 | [AllowAnonymous] 18 | [HttpGet] 19 | [IgnoreAntiforgeryToken] 20 | [Route(GetUrl, Name = "GetAntiforgeryTokens")] 21 | public IActionResult GetAntiforgeryTokens( 22 | [FromServices] IAntiforgery antiforgery, 23 | [FromServices] IOptions options) 24 | { 25 | AntiforgeryTokenSet tokens = antiforgery.GetTokens(HttpContext); 26 | 27 | var model = new AntiforgeryTokens() 28 | { 29 | CookieName = options.Value!.Cookie!.Name!, 30 | CookieValue = tokens.CookieToken!, 31 | FormFieldName = options.Value.FormFieldName, 32 | HeaderName = tokens.HeaderName!, 33 | RequestToken = tokens.RequestToken!, 34 | }; 35 | 36 | return Json(model); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/LoopbackOAuthEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Web; 5 | using Microsoft.AspNetCore.Authentication; 6 | using Microsoft.AspNetCore.Authentication.OAuth; 7 | 8 | namespace MartinCostello.Costellobot.Infrastructure; 9 | 10 | public sealed class LoopbackOAuthEvents : OAuthEvents 11 | { 12 | public override Task RedirectToAuthorizationEndpoint(RedirectContext context) 13 | { 14 | var query = new UriBuilder(context.RedirectUri).Uri.Query; 15 | var queryString = HttpUtility.ParseQueryString(query); 16 | 17 | // Ensure PKCE is in use 18 | queryString["code_challenge"].ShouldNotBeNullOrWhiteSpace(); 19 | queryString["code_challenge_method"].ShouldBe("S256"); 20 | 21 | var location = queryString["redirect_uri"]; 22 | var state = queryString["state"]; 23 | 24 | queryString.Clear(); 25 | 26 | var code = Guid.NewGuid().ToString(); 27 | 28 | queryString.Add("code", code); 29 | queryString.Add("state", state); 30 | 31 | var builder = new UriBuilder(location!) 32 | { 33 | Query = queryString.ToString() ?? string.Empty, 34 | }; 35 | 36 | context.RedirectUri = builder.ToString(); 37 | 38 | return base.RedirectToAuthorizationEndpoint(context); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Bundles/grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/main/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", 3 | "id": "grafana", 4 | "version": 1, 5 | "comment": "HTTP bundle for Grafana APIs.", 6 | "items": [ 7 | { 8 | "comment": "https://grafana.com/docs/grafana/latest/developers/http_api/annotations/#create-annotation", 9 | "uri": "https://grafana.local/api/annotations", 10 | "method": "POST", 11 | "requestHeaders": { 12 | "Accept": [ "application/json" ], 13 | "Authorization": [ "Bearer grafana-token" ] 14 | }, 15 | "contentHeaders": { 16 | "Content-Type": [ "application/json;charset=utf-8" ] 17 | }, 18 | "contentFormat": "json", 19 | "contentJson": { 20 | "message": "Annotation added", 21 | "id": 42 22 | } 23 | }, 24 | { 25 | "comment": "https://grafana.com/docs/grafana/latest/developers/http_api/annotations/#patch-annotation", 26 | "uri": "https://grafana.local/api/annotations/42", 27 | "method": "PATCH", 28 | "requestHeaders": { 29 | "Accept": [ "application/json" ], 30 | "Authorization": [ "Bearer grafana-token" ] 31 | }, 32 | "contentHeaders": { 33 | "Content-Type": [ "application/json;charset=utf-8" ] 34 | }, 35 | "contentFormat": "json", 36 | "contentJson": { 37 | "message": "Annotation patched" 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/Costellobot/Registries/GitHubReleasePackageRegistry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Caching.Hybrid; 5 | 6 | namespace MartinCostello.Costellobot.Registries; 7 | 8 | public sealed class GitHubReleasePackageRegistry( 9 | GitHubWebhookContext context, 10 | HybridCache cache) : GitHubPackageRegistry(context, cache) 11 | { 12 | private static readonly string[] CacheTags = ["all", "github-release"]; 13 | 14 | public override DependencyEcosystem Ecosystem => DependencyEcosystem.GitHubRelease; 15 | 16 | public override async Task> GetPackageOwnersAsync( 17 | RepositoryId repository, 18 | string id, 19 | string version, 20 | CancellationToken cancellationToken) 21 | { 22 | var slug = ParseRepository(id); 23 | 24 | if (slug != default) 25 | { 26 | string owner = slug.Owner; 27 | string name = slug.Name; 28 | 29 | var exists = await CachedExistsAsync( 30 | $"{owner}/{name}@release:{version}", 31 | () => RestClient.Repository.Release.Get(owner, name, version), 32 | CacheTags, 33 | cancellationToken); 34 | 35 | if (exists) 36 | { 37 | return [owner]; 38 | } 39 | } 40 | 41 | return []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Costellobot/DeploymentRules/PublicHolidayDeploymentRule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | 6 | namespace MartinCostello.Costellobot.DeploymentRules; 7 | 8 | public sealed partial class PublicHolidayDeploymentRule( 9 | PublicHolidayProvider provider, 10 | ILogger logger) : DeploymentRule 11 | { 12 | /// 13 | public override string Name => "Not-A-UK-Public-Holiday"; 14 | 15 | /// 16 | public override Task EvaluateAsync(WebhookEvent message, CancellationToken cancellationToken) 17 | { 18 | if (message is Octokit.Webhooks.Events.DeploymentProtectionRule.DeploymentProtectionRuleRequestedEvent) 19 | { 20 | return Task.FromResult(true); 21 | } 22 | 23 | var result = provider.IsPublicHoliday(); 24 | 25 | if (result) 26 | { 27 | Log.TodayIsAPublicHoliday(logger); 28 | } 29 | 30 | return Task.FromResult(!result); 31 | } 32 | 33 | [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] 34 | private static partial class Log 35 | { 36 | [LoggerMessage( 37 | EventId = 1, 38 | Level = LogLevel.Information, 39 | Message = "Deployment is not approved as today is a public holiday.")] 40 | public static partial void TodayIsAPublicHoliday(ILogger logger); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/InstallationCredentialStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Caching.Hybrid; 5 | 6 | namespace Octokit; 7 | 8 | public sealed class InstallationCredentialStore( 9 | IGitHubClientForApp client, 10 | HybridCache cache) : CredentialStore, GraphQL.ICredentialStore 11 | { 12 | public required long InstallationId { get; init; } 13 | 14 | public override async Task GetCredentials() 15 | { 16 | // See https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app#authentication-as-an-app-installation 17 | var token = await cache.GetOrCreateAsync( 18 | $"github:installation-credentials:{InstallationId}", 19 | (InstallationId, client.GitHubApps), 20 | static async (state, _) => 21 | { 22 | var accessToken = await state.GitHubApps.CreateInstallationToken(state.InstallationId); 23 | return accessToken.Token; 24 | }, 25 | CacheEntryOptions, 26 | CacheTags); 27 | 28 | return new Credentials(token, AuthenticationType.Oauth); 29 | } 30 | 31 | async Task GraphQL.ICredentialStore.GetCredentials(CancellationToken cancellationToken) 32 | { 33 | var credentials = await GetCredentials(); 34 | return credentials.GetToken(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/PublicHolidayProviderTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Time.Testing; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public class PublicHolidayProviderTests(ITestOutputHelper outputHelper) 9 | { 10 | [Theory] 11 | [InlineData("2025-12-24T12:34:56", false)] // Christmas Eve 12 | [InlineData("2025-12-25T12:34:56", true)] // Christmas Day 13 | [InlineData("2025-12-26T12:34:56", true)] // Boxing Day 14 | [InlineData("2025-12-27T12:34:56", false)] // A normal day 15 | [InlineData("2026-01-01T12:34:56", true)] // New Year's Day 16 | [InlineData("2026-01-02T12:34:56", false)] // A normal day 17 | public void IsPublicHoliday_Returns_Correct_Value(string utcNowString, bool expected) 18 | { 19 | // Arrange 20 | PublicHolidayProvider target = CreateTarget(utcNowString); 21 | 22 | // Act 23 | bool actual = target.IsPublicHoliday(); 24 | 25 | // Assert 26 | actual.ShouldBe(expected); 27 | } 28 | 29 | private PublicHolidayProvider CreateTarget(string utcNowString) 30 | { 31 | var utcNow = DateTimeOffset.Parse(utcNowString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); 32 | var timeProvider = new FakeTimeProvider(utcNow); 33 | 34 | return new( 35 | timeProvider, 36 | outputHelper.ToLogger()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/IssueCommentDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public sealed class IssueCommentDriver 11 | { 12 | public IssueCommentDriver(string body, string authorAssociation = "OWNER") 13 | { 14 | Owner = Sender = User = CreateUser(); 15 | Repository = Owner.CreateRepository(); 16 | Issue = Repository.CreateIssue(User); 17 | Comment = new CommentBuilder(body, authorAssociation); 18 | } 19 | 20 | public CommentBuilder Comment { get; set; } 21 | 22 | public IssueBuilder Issue { get; set; } 23 | 24 | public UserBuilder Owner { get; set; } 25 | 26 | public RepositoryBuilder Repository { get; set; } 27 | 28 | public UserBuilder Sender { get; set; } 29 | 30 | public UserBuilder User { get; set; } 31 | 32 | public object CreateWebhook(string action) 33 | { 34 | return new 35 | { 36 | action, 37 | comment = Comment.Build(), 38 | installation = new 39 | { 40 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 41 | }, 42 | issue = Issue.Build(), 43 | repository = Repository.Build(), 44 | sender = Sender.Build(), 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/WorkflowRunsClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public sealed class WorkflowRunsClient(IApiConnection connection) : IWorkflowRunsClient 7 | { 8 | public async Task> GetPendingDeploymentsAsync( 9 | string owner, 10 | string name, 11 | long runId) 12 | { 13 | // See https://docs.github.com/en/rest/reference/actions#get-pending-deployments-for-a-workflow-run 14 | var uri = new Uri($"repos/{owner}/{name}/actions/runs/{runId}/pending_deployments", UriKind.Relative); 15 | return await connection.GetAll(uri); 16 | } 17 | 18 | public async Task ReviewCustomProtectionRuleAsync( 19 | string deploymentCallbackUrl, 20 | ReviewDeploymentProtectionRule review, 21 | CancellationToken cancellationToken) 22 | { 23 | // See https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#review-custom-deployment-protection-rules-for-a-workflow-run 24 | var uri = new Uri(deploymentCallbackUrl, UriKind.Absolute); 25 | var status = await connection.Connection.Post(uri, review, "application/vnd.github+json", cancellationToken); 26 | 27 | if (status is not System.Net.HttpStatusCode.NoContent) 28 | { 29 | throw new ApiException("Failed to review custom deployment protection rule.", status); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Costellobot/IssueId.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot; 5 | 6 | /// 7 | /// A record representing a GitHub repository issue (or pull request) ID. 8 | /// 9 | /// The repository associated with the issue. 10 | /// The number of the issue or pull request. 11 | public sealed record IssueId(RepositoryId Repository, int Number) 12 | { 13 | /// 14 | /// Gets the repository owner. 15 | /// 16 | public string Owner => Repository.Owner; 17 | 18 | /// 19 | /// Gets the repository name. 20 | /// 21 | public string Name => Repository.Name; 22 | 23 | /// 24 | /// Creates a new instance of for the specified repository and issue number. 25 | /// 26 | /// The GitHub repository. 27 | /// The number of the issue or pull request. 28 | /// 29 | /// The associated with and . 30 | /// 31 | public static IssueId Create(Octokit.Webhooks.Models.Repository repository, long number) 32 | => new(RepositoryId.Create(repository), (int)number); 33 | 34 | /// 35 | public override string ToString() => FormattableString.Invariant($"{Repository.FullName}#{Number}"); 36 | } 37 | -------------------------------------------------------------------------------- /stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "companyName": "https://github.com/martincostello/costellobot", 6 | "copyrightText": "Copyright (c) {ownerName}, {year}. All rights reserved.\nLicensed under the {licenseName} license. See the {licenseFile} file in the project root for full license information.", 7 | "documentExposedElements": true, 8 | "documentInterfaces": true, 9 | "documentInternalElements": false, 10 | "documentPrivateElements": false, 11 | "documentPrivateFields": false, 12 | "fileNamingConvention": "metadata", 13 | "xmlHeader": false, 14 | "variables": { 15 | "licenseFile": "LICENSE", 16 | "licenseName": "Apache 2.0", 17 | "ownerName": "Martin Costello", 18 | "year": "2022" 19 | } 20 | }, 21 | "layoutRules": { 22 | "newlineAtEndOfFile": "require" 23 | }, 24 | "maintainabilityRules": { 25 | }, 26 | "namingRules": { 27 | "allowCommonHungarianPrefixes": true, 28 | "allowedHungarianPrefixes": [ 29 | ] 30 | }, 31 | "orderingRules": { 32 | "elementOrder": [ 33 | "kind", 34 | "accessibility", 35 | "constant", 36 | "static", 37 | "readonly" 38 | ], 39 | "systemUsingDirectivesFirst": true, 40 | "usingDirectivesPlacement": "outsideNamespace" 41 | }, 42 | "readabilityRules": { 43 | }, 44 | "spacingRules": { 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Costellobot.EndToEndTests/UITests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Playwright; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | [Category("UI")] 9 | public class UITests(AppFixture fixture, ITestOutputHelper outputHelper) : EndToEndTest(fixture, outputHelper), IAsyncLifetime 10 | { 11 | [Fact] 12 | public async Task Can_Load_Homepage() 13 | { 14 | // Arrange 15 | using var playwright = await Playwright.CreateAsync(); 16 | 17 | var browserType = playwright[BrowserType.Chromium]; 18 | 19 | await using var browser = await browserType.LaunchAsync(); 20 | await using var context = await browser.NewContextAsync(); 21 | 22 | var page = await context.NewPageAsync(); 23 | 24 | // Act 25 | await page.GotoAsync(Fixture.ServerAddress.ToString()); 26 | await page.WaitForLoadStateAsync(); 27 | 28 | // Assert 29 | await Assertions.Expect(page.Locator("id=sign-in")).ToBeVisibleAsync(); 30 | } 31 | 32 | public ValueTask InitializeAsync() 33 | { 34 | int exitCode = Program.Main(["install"]); 35 | 36 | if (exitCode != 0) 37 | { 38 | throw new InvalidOperationException($"Playwright exited with code {exitCode}."); 39 | } 40 | 41 | return ValueTask.CompletedTask; 42 | } 43 | 44 | public ValueTask DisposeAsync() 45 | { 46 | GC.SuppressFinalize(this); 47 | return ValueTask.CompletedTask; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Costellobot.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/AppFixtureExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Infrastructure; 5 | 6 | public static class AppFixtureExtensions 7 | { 8 | public static T ApproveDeployments(this T fixture, bool enabled = true) 9 | where T : AppFixture 10 | { 11 | fixture.OverrideConfiguration("Webhook:Deploy", enabled.ToString()); 12 | return fixture; 13 | } 14 | 15 | public static T ApprovePullRequests(this T fixture, bool enabled = true) 16 | where T : AppFixture 17 | { 18 | fixture.OverrideConfiguration("Webhook:Approve", enabled.ToString()); 19 | return fixture; 20 | } 21 | 22 | public static T AutoMergeEnabled(this T fixture, bool enabled = true) 23 | where T : AppFixture 24 | { 25 | fixture.OverrideConfiguration("Webhook:Automerge", enabled.ToString()); 26 | return fixture; 27 | } 28 | 29 | public static T FailedCheckRerunAttempts(this T fixture, int value) 30 | where T : AppFixture 31 | { 32 | fixture.OverrideConfiguration("Webhook:RerunFailedChecksAttempts", value.ToString(CultureInfo.InvariantCulture)); 33 | return fixture; 34 | } 35 | 36 | public static T ImplicitTrustEnabled(this T fixture, bool enabled = true) 37 | where T : AppFixture 38 | { 39 | fixture.OverrideConfiguration("Webhook:ImplicitTrust", enabled.ToString()); 40 | return fixture; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Registries/DockerPackageRegistryTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using MartinCostello.Costellobot.Infrastructure; 6 | 7 | namespace MartinCostello.Costellobot.Registries; 8 | 9 | public class DockerPackageRegistryTests 10 | { 11 | [Theory] 12 | [InlineData("devcontainers/dotnet", "latest", new[] { "mcr.microsoft.com" })] 13 | [InlineData("rhysd/actionlint", "1.7.7", new[] { "rhysd" })] 14 | public async Task Can_Get_Package_Owners(string id, string version, string[] expected) 15 | { 16 | // Arrange 17 | var repository = new RepositoryId("some-org", "some-repo"); 18 | 19 | var options = await new HttpClientInterceptorOptions() 20 | .ThrowsOnMissingRegistration() 21 | .RegisterBundleFromResourceStreamAsync("microsoft-artifact-registry", cancellationToken: TestContext.Current.CancellationToken); 22 | 23 | using var client = options.CreateHttpClient(); 24 | client.BaseAddress = new Uri("https://mcr.microsoft.com"); 25 | 26 | using var cache = new ApplicationCache(); 27 | 28 | var target = new DockerPackageRegistry(client, cache); 29 | 30 | // Act 31 | var actual = await target.GetPackageOwnersAsync( 32 | repository, 33 | id, 34 | version, 35 | TestContext.Current.CancellationToken); 36 | 37 | // Assert 38 | actual.ShouldNotBeNull(); 39 | actual.ShouldBe(expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Costellobot/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const cssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 4 | const miniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const removeEmptyScriptsPlugin = require('webpack-remove-empty-scripts'); 6 | 7 | module.exports = { 8 | devtool: 'source-map', 9 | entry: { 10 | css: path.resolve(__dirname, './styles/main.css'), 11 | js: path.resolve(__dirname, './scripts/main.ts'), 12 | }, 13 | mode: 'production', 14 | module: { 15 | rules: [ 16 | { 17 | test: /.css$/, 18 | use: [ 19 | miniCssExtractPlugin.loader, 20 | { loader: 'css-loader', options: { sourceMap: true } }, 21 | ], 22 | }, 23 | { 24 | test: /\.tsx?$/, 25 | use: 'ts-loader', 26 | exclude: /node_modules/, 27 | }, 28 | ], 29 | }, 30 | optimization: { 31 | minimize: true, 32 | minimizer: [ 33 | '...', 34 | new cssMinimizerPlugin(), 35 | ], 36 | }, 37 | output: { 38 | filename: '[name]/main.js', 39 | path: path.resolve(__dirname, 'wwwroot', 'static'), 40 | }, 41 | plugins: [ 42 | new miniCssExtractPlugin({ 43 | filename: '[name]/main.css' 44 | }), 45 | new removeEmptyScriptsPlugin(), 46 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en-gb/), 47 | ], 48 | resolve: { 49 | extensions: ['.css', '.tsx', '.ts', '.js'], 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Registries/RubyGemsPackageRegistryTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using MartinCostello.Costellobot.Infrastructure; 6 | 7 | namespace MartinCostello.Costellobot.Registries; 8 | 9 | public class RubyGemsPackageRegistryTests 10 | { 11 | [Theory] 12 | [InlineData("rack", "3.1.16", new[] { "tenderlove", "raggi", "chneukirchen", "ioquatix", "rafaelfranca", "eileencodes" })] 13 | [InlineData("foo", "1.0.0", new string[0])] 14 | public async Task Can_Get_Package_Owners(string id, string version, string[] expected) 15 | { 16 | // Arrange 17 | var repository = new RepositoryId("some-org", "some-repo"); 18 | 19 | var options = await new HttpClientInterceptorOptions() 20 | .ThrowsOnMissingRegistration() 21 | .RegisterBundleFromResourceStreamAsync("ruby-gems", cancellationToken: TestContext.Current.CancellationToken); 22 | 23 | using var client = options.CreateHttpClient(); 24 | client.BaseAddress = new Uri("https://rubygems.org"); 25 | 26 | using var cache = new ApplicationCache(); 27 | 28 | var target = new RubyGemsPackageRegistry(client, cache); 29 | 30 | // Act 31 | var actual = await target.GetPackageOwnersAsync( 32 | repository, 33 | id, 34 | version, 35 | TestContext.Current.CancellationToken); 36 | 37 | // Assert 38 | actual.ShouldNotBeNull(); 39 | actual.ShouldBe(expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/GitDiffParserTests.npm.OnePackageLastLine.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/Costellobot/package-lock.json b/src/Costellobot/package-lock.json 2 | index 7c4e0e2e3..c20fbc361 100644 3 | --- a/src/Costellobot/package-lock.json 4 | +++ b/src/Costellobot/package-lock.json 5 | @@ -32,7 +32,7 @@ 6 | "typescript": "^5.2.2", 7 | "webpack": "^5.88.2", 8 | "webpack-cli": "^5.1.4", 9 | - "webpack-remove-empty-scripts": "^1.0.3" 10 | + "webpack-remove-empty-scripts": "^1.0.4" 11 | } 12 | }, 13 | "node_modules/@aashutoshrathi/word-wrap": { 14 | @@ -11661,9 +11661,9 @@ 15 | } 16 | }, 17 | "node_modules/webpack-remove-empty-scripts": { 18 | - "version": "1.0.3", 19 | - "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.3.tgz", 20 | - "integrity": "sha512-1+Gg43r+4REb+3AUWbgjM3LIlxxE8YIqMnGpOmmhnaYK2rv4q58WbHYhZ9IRhTyt/+1qWoKQoPz/ebze5RnRYA==", 21 | + "version": "1.0.4", 22 | + "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.4.tgz", 23 | + "integrity": "sha512-W/Vd94oNXMsQam+W9G+aAzGgFlX1aItcJpkG3byuHGDaxyK3H17oD/b5RcqS/ZHzStIKepksdLDznejDhDUs+Q==", 24 | "dependencies": { 25 | "ansis": "1.5.2" 26 | }, 27 | diff --git a/src/Costellobot/package.json b/src/Costellobot/package.json 28 | index cc7e2728e..f478dd2d8 100644 29 | --- a/src/Costellobot/package.json 30 | +++ b/src/Costellobot/package.json 31 | @@ -42,6 +42,6 @@ 32 | "typescript": "^5.2.2", 33 | "webpack": "^5.88.2", 34 | "webpack-cli": "^5.1.4", 35 | - "webpack-remove-empty-scripts": "^1.0.3" 36 | + "webpack-remove-empty-scripts": "^1.0.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Pages/DeliveryPage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Playwright; 5 | 6 | namespace MartinCostello.Costellobot.Pages; 7 | 8 | public sealed class DeliveryPage(IPage page) : AppPage(page) 9 | { 10 | public async Task GuidAsync() 11 | => await Page.GetAttributeAsync(Selectors.DeliveryContent, "data-delivery-guid") ?? string.Empty; 12 | 13 | public async Task IdAsync() 14 | => await Page.GetAttributeAsync(Selectors.DeliveryContent, "data-delivery-id") ?? string.Empty; 15 | 16 | public async Task RequestHeadersAsync() 17 | => await Page.InnerTextAsync(Selectors.DeliveryContent) ?? string.Empty; 18 | 19 | public async Task RequestPayloadAsync() 20 | => await Page.InnerTextAsync(Selectors.RequestPayload) ?? string.Empty; 21 | 22 | public async Task RedeliverAsync() 23 | { 24 | await Page.ClickAsync(Selectors.RedeliverButton); 25 | 26 | var page = new DeliveriesPage(Page); 27 | await page.WaitForContentAsync(); 28 | 29 | return page; 30 | } 31 | 32 | public async Task WaitForContentAsync() 33 | => await Page.WaitForSelectorAsync(Selectors.DeliveryContent); 34 | 35 | private sealed class Selectors 36 | { 37 | internal const string DeliveryContent = "id=delivery-content"; 38 | internal const string RedeliverButton = "id=redeliver-payload"; 39 | internal const string RequestHeaders = "id=request-headers"; 40 | internal const string RequestPayload = "id=request-payload"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/IssueBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class IssueBuilder(RepositoryBuilder repository, UserBuilder? user = null) : ResponseBuilder 7 | { 8 | public string AuthorAssociation { get; set; } = "owner"; 9 | 10 | public int Number { get; set; } = RandomNumber(); 11 | 12 | public RepositoryBuilder Repository { get; set; } = repository; 13 | 14 | public PullRequestBuilder? PullRequest { get; set; } 15 | 16 | public string State { get; set; } = "open"; 17 | 18 | public string Title { get; set; } = RandomString(); 19 | 20 | public UserBuilder? User { get; set; } = user; 21 | 22 | public IssueBuilder CreatePullRequest() 23 | { 24 | PullRequest = new(Repository, User) 25 | { 26 | AuthorAssociation = AuthorAssociation, 27 | Number = Number, 28 | State = State, 29 | Title = Title, 30 | }; 31 | return this; 32 | } 33 | 34 | public override object Build() 35 | { 36 | return new 37 | { 38 | id = Id, 39 | author_association = AuthorAssociation, 40 | html_url = $"https://github.com/{Repository.FullName}/issues/{Number}", 41 | number = Number, 42 | pull_request = PullRequest?.Build(), 43 | state = State, 44 | title = Title, 45 | url = $"https://api.github.com/repos/{Repository.FullName}/issues/{Number}", 46 | user = (User ?? Repository.Owner).Build(), 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Costellobot/GitHubWebhookContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Options; 5 | using Octokit; 6 | using IConnection = Octokit.GraphQL.IConnection; 7 | 8 | namespace MartinCostello.Costellobot; 9 | 10 | public sealed class GitHubWebhookContext( 11 | IGitHubClientFactory clientFactory, 12 | IOptionsMonitor githubOptions, 13 | IOptionsMonitor webhookOptions) 14 | { 15 | private IGitHubClientForApp? _appClient; 16 | private IConnection? _graphQLClient; 17 | private IGitHubClientForInstallation? _installationClient; 18 | private IGitHubClientForUser? _userClient; 19 | 20 | public string AppId { get; set; } = string.Empty; 21 | 22 | public string AppName { get; set; } = string.Empty; 23 | 24 | public string AppSlug { get; set; } = string.Empty; 25 | 26 | public string InstallationId { get; set; } = string.Empty; 27 | 28 | public string InstallationOrganization { get; set; } = string.Empty; 29 | 30 | public IGitHubClientForApp AppClient => _appClient ??= clientFactory.CreateForApp(AppId); 31 | 32 | public IConnection GraphQLClient => _graphQLClient ??= clientFactory.CreateForGraphQL(InstallationId); 33 | 34 | public IGitHubClientForInstallation InstallationClient => _installationClient ??= clientFactory.CreateForInstallation(InstallationId); 35 | 36 | public IGitHubClientForUser UserClient => _userClient ??= clientFactory.CreateForUser(); 37 | 38 | public GitHubOptions GitHubOptions => githubOptions.CurrentValue; 39 | 40 | public WebhookOptions WebhookOptions => webhookOptions.CurrentValue; 41 | } 42 | -------------------------------------------------------------------------------- /src/Costellobot/scripts/Telemetry.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | import { getWebInstrumentations, initializeFaro } from '@grafana/faro-web-sdk'; 5 | import { TracingInstrumentation } from '@grafana/faro-web-tracing'; 6 | 7 | export class Telemetry { 8 | static initialize() { 9 | const getOption = (name: string): string | null => { 10 | const meta = document.querySelector(`meta[name='x-telemetry-${name}']`); 11 | 12 | let value: string | null = null; 13 | 14 | if (meta) { 15 | value = meta.getAttribute('content'); 16 | } 17 | 18 | return value; 19 | }; 20 | 21 | const url = getOption('collector-url'); 22 | const environment = getOption('service-environment')?.toLowerCase(); 23 | const namespace = getOption('service-namespace'); 24 | const version = getOption('service-version'); 25 | 26 | if (!url || !version || !environment) { 27 | return; 28 | } 29 | 30 | let tracking = undefined; 31 | const samplingRate = getOption('sample-rate'); 32 | 33 | if (samplingRate) { 34 | tracking = { 35 | samplingRate: parseFloat(samplingRate), 36 | }; 37 | } 38 | 39 | initializeFaro({ 40 | url, 41 | app: { 42 | environment, 43 | namespace, 44 | version, 45 | }, 46 | sessionTracking: tracking, 47 | instrumentations: [...getWebInstrumentations(), new TracingInstrumentation()], 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Costellobot.EndToEndTests/AppFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Headers; 5 | 6 | namespace MartinCostello.Costellobot; 7 | 8 | public class AppFixture 9 | { 10 | private const string ApplicationUrl = "APPLICATION_URL"; 11 | 12 | private readonly Uri? _serverAddress; 13 | 14 | public AppFixture() 15 | { 16 | string url = Environment.GetEnvironmentVariable(ApplicationUrl) ?? string.Empty; 17 | 18 | if (!Uri.TryCreate(url, UriKind.Absolute, out _serverAddress)) 19 | { 20 | _serverAddress = null; 21 | } 22 | } 23 | 24 | public Uri ServerAddress 25 | { 26 | get 27 | { 28 | Assert.SkipWhen(_serverAddress is null, $"The {ApplicationUrl} environment variable is not set or is not a valid absolute URI."); 29 | return _serverAddress!; 30 | } 31 | } 32 | 33 | private static string Version => Environment.GetEnvironmentVariable("GITHUB_RUN_ID") ?? "0"; 34 | 35 | public HttpClient CreateClient(bool allowAutoRedirect = false) 36 | { 37 | var handler = new HttpClientHandler() 38 | { 39 | AllowAutoRedirect = allowAutoRedirect, 40 | CheckCertificateRevocationList = true, 41 | }; 42 | 43 | var client = new HttpClient(handler: handler, disposeHandler: true) 44 | { 45 | BaseAddress = ServerAddress, 46 | }; 47 | 48 | client.DefaultRequestHeaders.UserAgent.Add( 49 | new ProductInfoHeaderValue( 50 | "Costellobot.EndToEndTests", Version)); 51 | 52 | return client; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/PullRequestReviewDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public sealed class PullRequestReviewDriver : PullRequestDriver 11 | { 12 | public PullRequestReviewDriver(string? reviewerLogin = null, string? authorLogin = null) 13 | : base(authorLogin) 14 | { 15 | Review = new(PullRequest, CreateUser(reviewerLogin)); 16 | Sender = Review.User; 17 | } 18 | 19 | public PullRequestReviewBuilder Review { get; set; } 20 | 21 | public static PullRequestReviewDriver FromUserForDependabot(string login = "martincostello") 22 | => new(login, DependabotCommitter); 23 | 24 | public PullRequestReviewDriver WithAuthorAssociation(string value) 25 | { 26 | Review.AuthorAssociation = value; 27 | return this; 28 | } 29 | 30 | public PullRequestReviewDriver WithState(string value) 31 | { 32 | Review.State = value; 33 | return this; 34 | } 35 | 36 | public override object CreateWebhook(string action) 37 | { 38 | return new 39 | { 40 | action, 41 | review = Review.Build(), 42 | pull_request = PullRequest.Build(), 43 | repository = PullRequest.Repository.Build(), 44 | sender = Sender.Build(), 45 | installation = new 46 | { 47 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 48 | }, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/costellobot-tests.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAx7Qt3vIYoUPtjahkpoGR3edGREickpbZbJ4w5ykQBT4DwvQP 3 | COxRzy9ry4Ex/7Cv+kpZX9PUl/DEBwhBzXyvDkqdIdCOt7/mhAyUzAgiGuhKq3lE 4 | Gdfcj5wV7rgmn2aeQE151v5YHRBXKVNRn60zExShhWq8MHZZSl+u1RgNFNetqNRW 5 | i3CLqL50n6U/KSCXrD/98KDpzPDB6m1kaCsp9ubBTZoDKj2mgpxTMgvayJ4CHSc1 6 | OUIkZqk1CgPs/+4Gn7W4oPcyLJpMDT8GWNvPW+V/F7Xsni3IUf2GKSxIPcK4OwNJ 7 | MzD3YaSD9wd3y3txleX+kx3EdcYV0nBuLx/H8QIDAQABAoIBACn08AI8KPKQu/Mc 8 | IvFcnZHaikfEXajqqomIfsvCM8x2KAIolpQQWmvGEcaywRdwPri+MCLS9YJqojS8 9 | Bl8ux/Sftn2pPKLcXYj2v7hSKSAwF6gJFUa8tGkzqOP7qpmozKzD2kpSK58HlNho 10 | 2ehLUkS6++h08U7ZPo9CpQ++Otlay2S3ObnC4wjwbK7vh6tMeWArqaRe2qQrwNEz 11 | wuW92Un5if46sCLtSHbJRczCob78DyZnn5NlSBGcnudANicZrTP9S3se7oZTUNjs 12 | tATMwkK5RqE5da9/c1OHIidOmGl456liwIsU4JAw2IAaz8wY7jJN3fKq2NGbemPx 13 | CUBw/AECgYEA+3SDnWPyOjve1abZxI3F3XA4tIV9QE2umHLE7hStvI6xjsELijSr 14 | JpHXk9evEnlBqsIlAqErMmxCmeTLBMvOYPx7OaAslSsKPrpQ6y7gDMedqadLkEbW 15 | ztTYGiQjoffRamW5DFmf+9Zy0xrEP8c5swoljhr46srrae/rQchmNUECgYEAy1A2 16 | Da0M7R89x8aSkx1sv+lr22f4J8uhCiiSzNPgNW2omgVD0R0JTGtfUIXEky4HKqHG 17 | m+uSk5+Liiw/hLpdVM29gjY8uNWTagDHOvKjQjnuAcEa721ZyQmHjx01bOoD8yb1 18 | iBjF4oRYLtzVSIKGAq7aaIn6W32FKiN+N1ypdrECgYAKY+IcWsjjhx3KDj7pVEJc 19 | yApPy0RFp/AS9IsWm1wicnL7xxZG+64mxdf2j+74AHKqL6N/3FEAoGCQI1gQNqus 20 | bkJZOzkcYM7nkKNPVHyFiqoFHJiOuZ6epUTYr62ZP7gzgUiILhviOBY+itiZGg5U 21 | S5MJsCug6AmaMwOxkCpJAQKBgHCtbpebvZs5AkNSsO8pkpi1o9oAQA7GEuUPYXR1 22 | REA4GwXpPxGItxuMMlHtIOm0y7H74JePMjfwZyRXq4hCxPD36TxeFL8XVtCbx87K 23 | pGJSuq5sOCJThzwctO1C8dX/x9qdT9xyZlFIqZqnNukttVmNUGw5c+/6m6+j5hKG 24 | n0CxAoGAfbXIscyIY++rZRfO8t+1BcbQnDKW/Q0OXrfe86u9wXe2/E48emKkrEr5 25 | KDmK8Ao+ZceMTjh+5FatfdtmCMMT7vcRw1tUB25K9e15J8jKA+10Sr5Ugslrd5qo 26 | qLe5TiN6v5Hcfp5D/YHwYk00pLlB7ZrkvyAeTe7xrMgTGB3jKlY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/PushDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public sealed class PushDriver 11 | { 12 | public PushDriver(bool isFork = false, string language = "C#") 13 | { 14 | Owner = Sender = User = CreateUser(); 15 | Repository = Owner.CreateRepository(isFork: isFork); 16 | Repository.Language = language; 17 | } 18 | 19 | public string After { get; set; } = Guid.NewGuid().ToString(); 20 | 21 | public bool Created { get; set; } 22 | 23 | public IList Commits { get; set; } = []; 24 | 25 | public bool Deleted { get; set; } 26 | 27 | public bool Forced { get; set; } 28 | 29 | public UserBuilder Owner { get; set; } 30 | 31 | public string Ref { get; set; } = "refs/heads/main"; 32 | 33 | public RepositoryBuilder Repository { get; set; } 34 | 35 | public UserBuilder Sender { get; set; } 36 | 37 | public UserBuilder User { get; set; } 38 | 39 | public object CreateWebhook() 40 | { 41 | return new 42 | { 43 | @ref = Ref, 44 | after = After, 45 | repository = Repository.Build(), 46 | installation = new 47 | { 48 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 49 | }, 50 | sender = Sender.Build(), 51 | created = Created, 52 | deleted = Deleted, 53 | forced = Forced, 54 | commits = Commits.Build(), 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: 8 | - main 9 | - dotnet-vnext 10 | - dotnet-nightly 11 | schedule: 12 | - cron: '0 6 * * MON' 13 | workflow_dispatch: 14 | 15 | permissions: {} 16 | 17 | jobs: 18 | analysis: 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: [ 'actions', 'csharp', 'javascript' ] 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 34 | with: 35 | filter: 'tree:0' 36 | persist-credentials: false 37 | show-progress: false 38 | 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 41 | with: 42 | build-mode: none 43 | languages: ${{ matrix.language }} 44 | queries: security-and-quality 45 | 46 | - name: Perform CodeQL Analysis 47 | uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 48 | with: 49 | category: '/language:${{ matrix.language }}' 50 | 51 | codeql: 52 | if: ${{ !cancelled() }} 53 | needs: [ analysis ] 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - name: Report status 58 | shell: bash 59 | env: 60 | SCAN_SUCCESS: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} 61 | run: | 62 | if [ "${SCAN_SUCCESS}" == "true" ] 63 | then 64 | echo 'CodeQL analysis successful ✅' 65 | else 66 | echo 'CodeQL analysis failed ❌' 67 | exit 1 68 | fi 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Costellobot 2 | 3 | [![Build status](https://github.com/martincostello/costellobot/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/martincostello/costellobot/actions/workflows/build.yml?query=branch%3Amain+event%3Apush) 4 | [![codecov](https://codecov.io/gh/martincostello/costellobot/branch/main/graph/badge.svg)](https://codecov.io/gh/martincostello/costellobot) 5 | [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/martincostello/costellobot/badge)](https://securityscorecards.dev/viewer/?uri=github.com/martincostello/costellobot) 6 | 7 | ## Introduction 8 | 9 | _Costellobot_ is a web application that handles automation for my GitHub repositories. 10 | 11 | For example, here's a pull request that the bot generated to update 12 | itself: [#5](https://github.com/martincostello/costellobot/pull/5) 13 | 14 | ## Building and Testing 15 | 16 | Compiling the application yourself requires Git and the 17 | [.NET SDK](https://dotnet.microsoft.com/download "Download the .NET SDK") 18 | to be installed. 19 | 20 | To build and test the application locally from a terminal/command-line, run the 21 | following set of commands: 22 | 23 | ```powershell 24 | git clone https://github.com/martincostello/costellobot.git 25 | cd costellobot 26 | ./build.ps1 27 | ``` 28 | 29 | ## Feedback 30 | 31 | Any feedback or issues can be added to the issues for this project in 32 | [GitHub](https://github.com/martincostello/costellobot/issues "Issues for this project on GitHub.com"). 33 | 34 | ## Repository 35 | 36 | The repository is hosted in 37 | [GitHub](https://github.com/martincostello/costellobot "This project on GitHub.com"): 38 | 39 | 40 | ## License 41 | 42 | This project is licensed under the 43 | [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt "The Apache 2.0 license") 44 | license. 45 | -------------------------------------------------------------------------------- /src/Costellobot/Models/DeliveryModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using Humanizer; 6 | 7 | namespace MartinCostello.Costellobot.Models; 8 | 9 | public sealed class DeliveryModel(JsonElement delivery) 10 | { 11 | public long Id => Delivery.GetProperty("id").GetInt64(); 12 | 13 | public string Action => Delivery.GetProperty("action").GetString() ?? "-"; 14 | 15 | public string DeliveryId => Delivery.GetProperty("guid").GetString() ?? "-"; 16 | 17 | public DateTimeOffset DeliveredAt => Delivery.GetProperty("delivered_at").GetDateTimeOffset(); 18 | 19 | public string Duration => Delivery 20 | .GetProperty("duration") 21 | .GetDouble() 22 | .Seconds() 23 | .TotalSeconds 24 | .ToString("N2", CultureInfo.InvariantCulture); 25 | 26 | public string Event => Delivery.GetProperty("event").GetString() ?? "-"; 27 | 28 | public bool Redelivery => Delivery.GetProperty("redelivery").GetBoolean(); 29 | 30 | public string RepositoryId { get; set; } = "-"; 31 | 32 | public IDictionary RequestHeaders { get; } = new Dictionary(); 33 | 34 | public string RequestPayload { get; set; } = string.Empty; 35 | 36 | public string RequestUrl => Delivery.GetProperty("url").GetString() ?? "-"; 37 | 38 | public IDictionary ResponseHeaders { get; } = new Dictionary(); 39 | 40 | public string ResponseBody { get; set; } = string.Empty; 41 | 42 | public string? ResponseStatus => Delivery.GetProperty("status").GetString(); 43 | 44 | public int ResponseStatusCode => Delivery.GetProperty("status_code").GetInt32(); 45 | 46 | private JsonElement Delivery { get; set; } = delivery; 47 | } 48 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To contribute changes (source code, scripts, configuration) to this repository please follow the steps below. 4 | These steps are a guideline for contributing and do not necessarily need to be followed for all changes. 5 | 6 | 1. If you intend to fix a bug please create an issue before forking the repository. 7 | 1. Fork the `main` branch of this repository from the latest commit. 8 | 1. Create a branch from your fork's `main` branch to help isolate your changes from any further work on `main`. If fixing an issue try to reference its name in your branch name (e.g. `issue-123`) to make changes easier to track the changes. 9 | 1. Work on your proposed changes on your fork. If you are fixing an issue include at least one unit test that reproduces it if the code changes to fix it have not been applied; if you are adding new functionality please include unit tests appropriate to the changes you are making. 10 | 1. When you think your changes are complete, test that the code builds cleanly using `build.ps1`. There should be no compiler warnings and all tests should pass. 11 | 1. Once your changes build cleanly locally submit a Pull Request back to the `main` branch from your fork's branch. Ideally commits to your branch should be squashed before creating the Pull Request. If the Pull Request fixes an issue please reference it in the title and/or description. Please keep changes focused around a specific topic rather than include multiple types of changes in a single Pull Request. 12 | 1. After your Pull Request is created it will build against the repository's continuous integrations. 13 | 1. Once the Pull Request has been reviewed by the project's [contributors](https://github.com/martincostello/costellobot/graphs/contributors) and the status checks pass your Pull Request will be merged back to the `main` branch, assuming that the changes are deemed appropriate. 14 | -------------------------------------------------------------------------------- /src/Costellobot/Octokit/IGitHubClientExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace Octokit; 5 | 6 | public static class IGitHubClientExtensions 7 | { 8 | public static IWorkflowRunsClient WorkflowRuns(this IGitHubClient client) 9 | => new WorkflowRunsClient(new ApiConnection(client.Connection)); 10 | 11 | public static async Task GetDiffAsync( 12 | this IGitHubClient client, 13 | string pullRequestUrl, 14 | CancellationToken cancellationToken) 15 | { 16 | // See https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request 17 | var parameters = new Dictionary(0); 18 | 19 | var response = await client.Connection.Get( 20 | new(pullRequestUrl, UriKind.Absolute), 21 | parameters, 22 | "application/vnd.github.v3.diff", 23 | cancellationToken); 24 | 25 | return response.Body; 26 | } 27 | 28 | public static async Task RepositoryDispatchAsync( 29 | this IGitHubClient client, 30 | string owner, 31 | string name, 32 | object body, 33 | CancellationToken cancellationToken) 34 | { 35 | // See https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#create-a-repository-dispatch-event 36 | var uri = new Uri($"repos/{owner}/{name}/dispatches", UriKind.Relative); 37 | var status = await client.Connection.Post(uri, body, "application/vnd.github+json", cancellationToken); 38 | 39 | if (status is not System.Net.HttpStatusCode.NoContent) 40 | { 41 | throw new ApiException($"Failed to create repository dispatch event for {owner}/{name}.", status); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Costellobot/ChannelQueue`1.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using System.Threading.Channels; 6 | 7 | namespace MartinCostello.Costellobot; 8 | 9 | public abstract class ChannelQueue 10 | { 11 | private const int Capacity = 20; 12 | 13 | private readonly ConcurrentQueue _history; 14 | private readonly Channel _queue; 15 | 16 | protected ChannelQueue() 17 | { 18 | var channelOptions = new BoundedChannelOptions(Capacity) 19 | { 20 | FullMode = BoundedChannelFullMode.DropOldest, 21 | SingleReader = true, 22 | SingleWriter = false, 23 | }; 24 | 25 | _queue = Channel.CreateBounded(channelOptions); 26 | _history = new ConcurrentQueue(); 27 | } 28 | 29 | public virtual async Task DequeueAsync(CancellationToken cancellationToken) 30 | { 31 | T? item = default; 32 | 33 | try 34 | { 35 | if (await _queue.Reader.WaitToReadAsync(cancellationToken)) 36 | { 37 | item = await _queue.Reader.ReadAsync(cancellationToken); 38 | } 39 | } 40 | catch (OperationCanceledException) 41 | { 42 | // Ignore 43 | } 44 | 45 | return item; 46 | } 47 | 48 | public virtual bool Enqueue(T item) 49 | { 50 | bool written = _queue.Writer.TryWrite(item); 51 | 52 | if (written) 53 | { 54 | _history.Enqueue(item); 55 | 56 | while (_history.Count > Capacity) 57 | { 58 | _ = _history.TryDequeue(out _); 59 | } 60 | } 61 | 62 | return written; 63 | } 64 | 65 | public IList History() => [.. _history]; 66 | } 67 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/DeploymentProtectionRuleDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Cryptography; 5 | using MartinCostello.Costellobot.Builders; 6 | 7 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 8 | 9 | namespace MartinCostello.Costellobot.Drivers; 10 | 11 | public sealed class DeploymentProtectionRuleDriver 12 | { 13 | public DeploymentProtectionRuleDriver(DeploymentBuilder deployment) 14 | { 15 | Owner = CreateUser(); 16 | Repository = Owner.CreateRepository(); 17 | Deployment = deployment; 18 | Environment = Deployment.Environment ?? "production"; 19 | } 20 | 21 | public DeploymentBuilder Deployment { get; set; } 22 | 23 | public string Environment { get; set; } 24 | 25 | public string Event { get; set; } = "push"; 26 | 27 | public UserBuilder Owner { get; set; } 28 | 29 | public IList PullRequests { get; set; } = []; 30 | 31 | public RepositoryBuilder Repository { get; set; } 32 | 33 | public long RunId { get; set; } = RandomNumberGenerator.GetInt32(int.MaxValue); 34 | 35 | public object CreateWebhook(string action) 36 | { 37 | return new 38 | { 39 | action, 40 | environment = Environment, 41 | @event = Event, 42 | deployment_callback_url = $"https://api.github.com/repos/{Repository.FullName}/actions/runs/{RunId}/deployment_protection_rule", 43 | deployment = Deployment.Build(), 44 | pull_requests = PullRequests.Build(), 45 | repository = Repository.Build(), 46 | installation = new 47 | { 48 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 49 | }, 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/GitHubWebhookServiceTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Azure.Core.Amqp; 5 | using Azure.Messaging.ServiceBus; 6 | using NSubstitute; 7 | 8 | namespace MartinCostello.Costellobot; 9 | 10 | public class GitHubWebhookServiceTests(ITestOutputHelper outputHelper) 11 | { 12 | [Theory] 13 | [InlineData("", "")] 14 | [InlineData("application/octet-stream", "github-webhook")] 15 | [InlineData("application/json", "foo")] 16 | public async Task ProcessAsync_Validates_Message(string contentType, string subject) 17 | { 18 | // Arrange 19 | var body = AmqpMessageBody.FromValue(string.Empty); 20 | var amqp = new AmqpAnnotatedMessage(body); 21 | 22 | amqp.Properties.ContentType = contentType; 23 | amqp.Properties.Subject = subject; 24 | 25 | var message = ServiceBusReceivedMessage.FromAmqpMessage(amqp, BinaryData.FromBytes([])); 26 | 27 | var options = new WebhookOptions().ToMonitor(); 28 | var serviceProvider = Substitute.For(); 29 | 30 | await using var client = Substitute.For(); 31 | await using var receiver = Substitute.For(); 32 | 33 | var processor = new GitHubMessageProcessor( 34 | serviceProvider, 35 | options, 36 | outputHelper.ToLogger()); 37 | 38 | var target = new GitHubWebhookService( 39 | client, 40 | processor, 41 | options, 42 | outputHelper.ToLogger()); 43 | 44 | var args = new ProcessMessageEventArgs( 45 | message, 46 | receiver, 47 | TestContext.Current.CancellationToken); 48 | 49 | // Act and Assert 50 | await Should.ThrowAsync(() => target.ProcessAsync(args)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Bundles/github-submodules.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/main/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", 3 | "id": "github-submodules", 4 | "version": 1, 5 | "comment": "HTTP bundle for GitHub submodule content.", 6 | "items": [ 7 | { 8 | "comment": "dotnet/aspnetcore content for src/submodules/foo", 9 | "uri": "https://api.github.com/repos/dotnet/aspnetcore/contents/src/submodules/foo", 10 | "contentFormat": "json", 11 | "contentJson": { 12 | "message": "Not Found", 13 | "documentation_url": "https://docs.github.com/rest/reference/repos#get-repository-content" 14 | } 15 | }, 16 | { 17 | "comment": "dotnet/aspnetcore content for src/submodules/googletest", 18 | "uri": "https://api.github.com/repos/dotnet/aspnetcore/contents/src/submodules/googletest", 19 | "contentFormat": "json", 20 | "contentJson": { 21 | "name": "googletest", 22 | "path": "src/submodules/googletest", 23 | "sha": "7735334a46da480a749945c0f645155d90d73855", 24 | "size": 0, 25 | "url": "https://api.github.com/repos/dotnet/aspnetcore/contents/src/submodules/googletest?ref=main", 26 | "html_url": "https://github.com/google/googletest/tree/7735334a46da480a749945c0f645155d90d73855", 27 | "git_url": "https://api.github.com/repos/google/googletest/git/trees/7735334a46da480a749945c0f645155d90d73855", 28 | "download_url": null, 29 | "type": "submodule", 30 | "submodule_git_url": "https://github.com/google/googletest", 31 | "_links": { 32 | "self": "https://api.github.com/repos/dotnet/aspnetcore/contents/src/submodules/googletest?ref=main", 33 | "git": "https://api.github.com/repos/google/googletest/git/trees/7735334a46da480a749945c0f645155d90d73855", 34 | "html": "https://github.com/google/googletest/tree/7735334a46da480a749945c0f645155d90d73855" 35 | } 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/InMemoryTrustStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using MartinCostello.Costellobot.Models; 6 | 7 | namespace MartinCostello.Costellobot.Infrastructure; 8 | 9 | internal sealed class InMemoryTrustStore : ITrustStore 10 | { 11 | private readonly ConcurrentDictionary<(DependencyEcosystem Ecosystem, string Id, string Version), bool> _trustStore = new(); 12 | 13 | public int Count => _trustStore.Count; 14 | 15 | public Task DistrustAllAsync(CancellationToken cancellationToken = default) 16 | { 17 | _trustStore.Clear(); 18 | return Task.CompletedTask; 19 | } 20 | 21 | public Task DistrustAsync(DependencyEcosystem ecosystem, string id, string version, CancellationToken cancellationToken = default) 22 | { 23 | _trustStore.Remove((ecosystem, id, version), out _); 24 | return Task.CompletedTask; 25 | } 26 | 27 | public Task> GetTrustAsync(DependencyEcosystem ecosystem, CancellationToken cancellationToken = default) 28 | { 29 | var trusted = _trustStore.Keys 30 | .Where((p) => p.Ecosystem == ecosystem) 31 | .Select((p) => new TrustedDependency(p.Id, p.Version)) 32 | .ToList(); 33 | 34 | return Task.FromResult>(trusted); 35 | } 36 | 37 | public Task IsTrustedAsync(DependencyEcosystem ecosystem, string id, string version, CancellationToken cancellationToken = default) 38 | { 39 | bool isTrusted = _trustStore.ContainsKey((ecosystem, id, version)); 40 | return Task.FromResult(isTrusted); 41 | } 42 | 43 | public Task TrustAsync(DependencyEcosystem ecosystem, string id, string version, CancellationToken cancellationToken = default) 44 | { 45 | _trustStore[(ecosystem, id, version)] = true; 46 | return Task.CompletedTask; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Builders/RepositoryBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Costellobot.Builders; 5 | 6 | public sealed class RepositoryBuilder(UserBuilder owner, string? name = null) : ResponseBuilder 7 | { 8 | public bool? AllowMergeCommit { get; set; } = true; 9 | 10 | public bool? AllowRebaseMerge { get; set; } = true; 11 | 12 | public bool? AllowSquashMerge { get; set; } = true; 13 | 14 | public string FullName => $"{Owner.Login}/{Name}"; 15 | 16 | public bool IsArchived { get; set; } 17 | 18 | public bool IsFork { get; set; } 19 | 20 | public bool IsPrivate { get; set; } 21 | 22 | public string Language { get; set; } = "C#"; 23 | 24 | public string Name { get; set; } = name ?? RandomString(); 25 | 26 | public UserBuilder Owner { get; set; } = owner; 27 | 28 | public GitHubCommitBuilder CreateCommit(UserBuilder? author = null) 29 | => new(this) { Author = author }; 30 | 31 | public IssueBuilder CreateIssue(UserBuilder? user = null) 32 | => new(this, user); 33 | 34 | public PullRequestBuilder CreatePullRequest(UserBuilder? user = null) 35 | => new(this, user); 36 | 37 | public WorkflowRunBuilder CreateWorkflowRun() 38 | => new(this); 39 | 40 | public override object Build() 41 | { 42 | return new 43 | { 44 | allow_merge_commit = AllowMergeCommit, 45 | allow_rebase_merge = AllowRebaseMerge, 46 | allow_squash_merge = AllowSquashMerge, 47 | archived = IsArchived, 48 | fork = IsFork, 49 | full_name = FullName, 50 | html_url = $"https://github.com/{FullName}", 51 | id = Id, 52 | language = Language, 53 | name = Name, 54 | owner = Owner.Build(), 55 | @private = IsPrivate, 56 | url = $"https://api.github.com/repos/{FullName}", 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Costellobot/Registries/GitSubmodulePackageRegistry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Caching.Hybrid; 5 | using Octokit; 6 | 7 | namespace MartinCostello.Costellobot.Registries; 8 | 9 | public sealed class GitSubmodulePackageRegistry( 10 | GitHubWebhookContext context, 11 | HybridCache cache) : GitHubPackageRegistry(context, cache) 12 | { 13 | private static readonly string[] CacheTags = ["all", "github-submodule"]; 14 | 15 | public override DependencyEcosystem Ecosystem => DependencyEcosystem.GitSubmodule; 16 | 17 | public override async Task> GetPackageOwnersAsync( 18 | RepositoryId repository, 19 | string id, 20 | string version, 21 | CancellationToken cancellationToken) 22 | { 23 | IReadOnlyList items = await Cache.GetOrCreateAsync( 24 | $"git-submodule:{repository.Owner}/{repository.Name}:{id}", 25 | (RestClient, repository, id), 26 | static async (context, _) => 27 | { 28 | try 29 | { 30 | return await context.RestClient.Repository.Content.GetAllContents( 31 | context.repository.Owner, 32 | context.repository.Name, 33 | context.id); 34 | } 35 | catch (NotFoundException) 36 | { 37 | return []; 38 | } 39 | }, 40 | CacheEntryOptions, 41 | CacheTags, 42 | cancellationToken); 43 | 44 | if (items.Count is 1 && 45 | items[0] is { SubmoduleGitUrl: not null } item) 46 | { 47 | string url = item.SubmoduleGitUrl; 48 | string urlWithoutRepoName = string.Join('/', url.Split('/').SkipLast(1)); 49 | 50 | return [urlWithoutRepoName]; 51 | } 52 | 53 | return []; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/CheckSuiteDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public sealed class CheckSuiteDriver 11 | { 12 | public CheckSuiteDriver(string? login = null, string? conclusion = null) 13 | { 14 | Owner = CreateUser(login); 15 | Repository = Owner.CreateRepository(); 16 | PullRequest = Repository.CreatePullRequest(); 17 | WorkflowRun = Repository.CreateWorkflowRun(); 18 | CheckSuite = CreateCheckSuite(conclusion); 19 | } 20 | 21 | public IList CheckRuns { get; set; } = []; 22 | 23 | public CheckSuiteBuilder CheckSuite { get; set; } 24 | 25 | public UserBuilder Owner { get; set; } 26 | 27 | public PullRequestBuilder PullRequest { get; set; } 28 | 29 | public RepositoryBuilder Repository { get; set; } 30 | 31 | public WorkflowRunBuilder WorkflowRun { get; set; } 32 | 33 | public CheckSuiteDriver WithCheckRun(Func configure) 34 | { 35 | CheckRuns.Add(configure(this.PullRequest)); 36 | return this; 37 | } 38 | 39 | public object CreateWebhook(string action) 40 | { 41 | return new 42 | { 43 | action, 44 | check_suite = CheckSuite.Build(), 45 | repository = CheckSuite.Repository.Build(), 46 | installation = new 47 | { 48 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 49 | }, 50 | }; 51 | } 52 | 53 | private CheckSuiteBuilder CreateCheckSuite( 54 | string? conclusion = null, 55 | bool rerequestable = true) 56 | { 57 | return new(Repository, "completed", conclusion ?? "failure") 58 | { 59 | Rerequestable = rerequestable, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Costellobot/WellKnownGitHubEvents.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Octokit.Webhooks; 5 | using Octokit.Webhooks.Events.CheckSuite; 6 | using Octokit.Webhooks.Events.DeploymentProtectionRule; 7 | using Octokit.Webhooks.Events.DeploymentStatus; 8 | using Octokit.Webhooks.Events.IssueComment; 9 | using Octokit.Webhooks.Events.PullRequest; 10 | using Octokit.Webhooks.Events.PullRequestReview; 11 | 12 | namespace MartinCostello.Costellobot; 13 | 14 | /// 15 | /// Defines all of the known/consumed GitHub webhook events. 16 | /// 17 | public static class WellKnownGitHubEvents 18 | { 19 | private static readonly HashSet<(string? Event, string? Action)> KnownEvents = 20 | [ 21 | (WebhookEventType.CheckSuite, CheckSuiteActionValue.Completed), 22 | (WebhookEventType.DeploymentProtectionRule, DeploymentProtectionRuleActionValue.Requested), 23 | (WebhookEventType.DeploymentStatus, DeploymentStatusActionValue.Created), 24 | (WebhookEventType.IssueComment, IssueCommentActionValue.Created), 25 | (WebhookEventType.Ping, null), 26 | (WebhookEventType.Push, null), 27 | (WebhookEventType.PullRequest, PullRequestActionValue.Labeled), 28 | (WebhookEventType.PullRequest, PullRequestActionValue.Opened), 29 | (WebhookEventType.PullRequestReview, PullRequestReviewActionValue.Submitted), 30 | (WebhookEventType.RepositoryDispatch, RepositoryDispatchActionValue.DeploymentCompleted), 31 | (WebhookEventType.RepositoryDispatch, RepositoryDispatchActionValue.DeploymentStarted), 32 | ]; 33 | 34 | public static bool IsKnown(GitHubEvent message) 35 | => KnownEvents.Contains((message.Headers.Event, message.Event.Action)); 36 | 37 | public static class RepositoryDispatchActionValue 38 | { 39 | public const string DeploymentCompleted = "deployment_completed"; 40 | public const string DeploymentStarted = "deployment_started"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Costellobot/Authorization/HealthProbeHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Cryptography; 5 | using Microsoft.AspNetCore.Authorization; 6 | 7 | namespace MartinCostello.Costellobot.Authorization; 8 | 9 | /// 10 | /// A class representing an authorization handler for automated health probes. 11 | /// 12 | /// The to use. 13 | public sealed partial class HealthProbeHandler(IConfiguration configuration) : AuthorizationHandler 14 | { 15 | private readonly string? _encryptionKeyHash = GetEncryptionKeyHash(configuration); 16 | 17 | /// 18 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HealthProbeRequirement requirement) 19 | { 20 | if (_encryptionKeyHash is { Length: > 0 } && context.Resource is HttpContext httpContext) 21 | { 22 | // See https://learn.microsoft.com/azure/app-service/monitor-instances-health-check?tabs=dotnet#authentication-and-security 23 | var token = 24 | httpContext.Request.Headers["x-health-probe-token"].FirstOrDefault() ?? 25 | httpContext.Request.Headers["x-ms-auth-internal-token"].FirstOrDefault(); 26 | 27 | if (string.Equals(token, _encryptionKeyHash, StringComparison.Ordinal)) 28 | { 29 | context.Succeed(requirement); 30 | } 31 | } 32 | 33 | return Task.CompletedTask; 34 | } 35 | 36 | private static string? GetEncryptionKeyHash(IConfiguration configuration) 37 | { 38 | var key = configuration["WEBSITE_AUTH_ENCRYPTION_KEY"]; 39 | 40 | if (string.IsNullOrWhiteSpace(key) || key.Length < 32) 41 | { 42 | return null; 43 | } 44 | 45 | var keyBytes = Encoding.UTF8.GetBytes(key); 46 | var hashBytes = SHA256.HashData(keyBytes); 47 | 48 | return Convert.ToBase64String(hashBytes); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Costellobot.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | MartinCostello.Costellobot 5 | net10.0 6 | true 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | true 38 | 70 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Drivers/PullRequestDriver.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Costellobot.Builders; 5 | 6 | using static MartinCostello.Costellobot.Builders.GitHubFixtures; 7 | 8 | namespace MartinCostello.Costellobot.Drivers; 9 | 10 | public class PullRequestDriver 11 | { 12 | public PullRequestDriver(string? login = null) 13 | { 14 | User = CreateUser(login); 15 | Sender = User; 16 | Owner = CreateUser(); 17 | Repository = Owner.CreateRepository(); 18 | PullRequest = Repository.CreatePullRequest(User); 19 | Commit = PullRequest.CreateCommit(); 20 | } 21 | 22 | public GitHubCommitBuilder Commit { get; set; } 23 | 24 | public LabelBuilder? Label { get; set; } 25 | 26 | public UserBuilder Owner { get; set; } 27 | 28 | public PullRequestBuilder PullRequest { get; set; } 29 | 30 | public RepositoryBuilder Repository { get; set; } 31 | 32 | public UserBuilder Sender { get; set; } 33 | 34 | public UserBuilder User { get; set; } 35 | 36 | public static PullRequestDriver ForDependabot() 37 | => new(DependabotCommitter); 38 | 39 | public static PullRequestDriver ForRenovate() 40 | => new(RenovateCommitter); 41 | 42 | public PullRequestDriver WithCommitMessage(string message) 43 | { 44 | Commit.Message = message; 45 | return this; 46 | } 47 | 48 | public PullRequestDriver WithDiff(string diff) 49 | { 50 | PullRequest.Diff = diff; 51 | return this; 52 | } 53 | 54 | public virtual object CreateWebhook(string action) 55 | { 56 | return new 57 | { 58 | action, 59 | number = PullRequest.Number, 60 | pull_request = PullRequest.Build(), 61 | repository = PullRequest.Repository.Build(), 62 | installation = new 63 | { 64 | id = long.Parse(InstallationId, CultureInfo.InvariantCulture), 65 | }, 66 | label = Label?.Build(), 67 | sender = Sender.Build(), 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Infrastructure/ApplicationCache.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.Caching.Hybrid; 5 | using Microsoft.Extensions.Caching.Memory; 6 | 7 | namespace MartinCostello.Costellobot.Infrastructure; 8 | 9 | internal sealed class ApplicationCache : HybridCache, IDisposable 10 | { 11 | private readonly MemoryCache _cache = new(new MemoryCacheOptions()); 12 | 13 | public void Dispose() => _cache.Dispose(); 14 | 15 | public override async ValueTask GetOrCreateAsync( 16 | string key, 17 | TState state, 18 | Func> factory, 19 | HybridCacheEntryOptions? options = null, 20 | IEnumerable? tags = null, 21 | CancellationToken cancellationToken = default) 22 | { 23 | var result = await _cache.GetOrCreateAsync(key, async (entry) => 24 | { 25 | entry.AbsoluteExpirationRelativeToNow = options?.Expiration; 26 | return await factory(state, cancellationToken); 27 | }); 28 | 29 | return result!; 30 | } 31 | 32 | public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) 33 | { 34 | _cache.Remove(key); 35 | return ValueTask.CompletedTask; 36 | } 37 | 38 | public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default) 39 | { 40 | if (tag is "all") 41 | { 42 | _cache.Compact(percentage: 100); 43 | } 44 | 45 | return ValueTask.CompletedTask; 46 | } 47 | 48 | public override ValueTask SetAsync( 49 | string key, 50 | T value, 51 | HybridCacheEntryOptions? options = null, 52 | IEnumerable? tags = null, 53 | CancellationToken cancellationToken = default) 54 | { 55 | var entryOptions = new MemoryCacheEntryOptions() 56 | { 57 | AbsoluteExpirationRelativeToNow = options?.Expiration, 58 | }; 59 | 60 | _cache.Set(key, value, entryOptions); 61 | 62 | return ValueTask.CompletedTask; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Costellobot.Tests/Registries/NuGetPackageRegistryTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2022. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using MartinCostello.Costellobot.Infrastructure; 6 | 7 | namespace MartinCostello.Costellobot.Registries; 8 | 9 | public class NuGetPackageRegistryTests(ITestOutputHelper outputHelper) 10 | { 11 | [Theory] 12 | [InlineData("AWSSDK.S3", "3.7.9.32", new[] { "awsdotnet" })] 13 | [InlineData("JustEat.HttpClientInterception", "3.1.1", new[] { "JUSTEAT_OSS" })] 14 | [InlineData("MartinCostello.Logging.XUnit", "0.3.0", new[] { "martin_costello" })] 15 | [InlineData("Microsoft.AspNetCore.Mvc.Testing", "6.0.7", new[] { "aspnet", "Microsoft" })] 16 | [InlineData("Newtonsoft.Json", "13.0.1", new[] { "dotnetfoundation", "jamesnk", "newtonsoft" })] 17 | [InlineData("Octokit.GraphQL", "0.1.9-beta", new[] { "GitHub", "grokys", "jcansdale", "nickfloyd", "StanleyGoldman" })] 18 | [InlineData("Octokit.Webhooks.AspNetCore", "1.4.0", new[] { "GitHub", "kfcampbell" })] 19 | [InlineData("foo", "1.0.0", new string[0])] 20 | [InlineData("System.Text.Json", "malformed", new string[0])] 21 | public async Task Can_Get_Package_Owners(string id, string version, string[] expected) 22 | { 23 | // Arrange 24 | var repository = new RepositoryId("some-org", "some-repo"); 25 | 26 | var options = await new HttpClientInterceptorOptions() 27 | .ThrowsOnMissingRegistration() 28 | .RegisterNuGetBundleAsync(TestContext.Current.CancellationToken); 29 | 30 | using var client = options.CreateHttpClient(); 31 | client.BaseAddress = new Uri("https://api.nuget.org"); 32 | 33 | using var cache = new ApplicationCache(); 34 | 35 | var target = new NuGetPackageRegistry(client, cache, outputHelper.ToLogger()); 36 | 37 | // Act 38 | var actual = await target.GetPackageOwnersAsync( 39 | repository, 40 | id, 41 | version, 42 | TestContext.Current.CancellationToken); 43 | 44 | // Assert 45 | actual.ShouldNotBeNull(); 46 | actual.ShouldBe(expected); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something not behaving as expected? 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/costellobot/blob/main/README.md) before submitting a bug report. 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Expected behaviour 19 | description: A clear and concise description of what you expected to happen. 20 | validations: 21 | required: false 22 | - type: textarea 23 | attributes: 24 | label: Actual behaviour 25 | description: What actually happens. 26 | validations: 27 | required: false 28 | - type: textarea 29 | attributes: 30 | label: Steps to reproduce 31 | description: | 32 | Provide a link to a [minimalistic project which reproduces this issue (repro)](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 33 | Code snippets, such as a failing unit test or small console app, which demonstrate the issue wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 34 | 35 | This issue will be closed if: 36 | - The behaviour you're reporting cannot be easily reproduced. 37 | - The issue is a duplicate of an existing issue. 38 | - The behaviour you're reporting is by design. 39 | validations: 40 | required: false 41 | - type: textarea 42 | attributes: 43 | label: Exception(s) (if any) 44 | description: Include any exception(s) and/or stack trace(s) you get when facing this issue. 45 | render: text 46 | validations: 47 | required: false 48 | - type: textarea 49 | attributes: 50 | label: Anything else? 51 | description: | 52 | Links? References? Anything that will give us more context about the issue you are encountering is useful. 53 | 54 | 💡Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 55 | validations: 56 | required: false 57 | -------------------------------------------------------------------------------- /src/Costellobot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "costellobot", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "description": "GitHub automation for martincostello's repositories.", 7 | "scripts": { 8 | "build": "npm run compile && npm run format && npm run lint && npm test", 9 | "compile": "webpack", 10 | "format": "prettier --write scripts/**/*.ts && stylelint --fix lax styles/**/*.css", 11 | "format-check": "prettier --check scripts/**/*.ts && stylelint --fix styles/**/*.css", 12 | "lint": "eslint scripts", 13 | "test": "vitest run", 14 | "watch": "webpack --watch" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/martincostello/costellobot.git" 19 | }, 20 | "author": "martincostello", 21 | "license": "Apache-2.0", 22 | "dependencies": { 23 | "@grafana/faro-web-sdk": "2.1.0", 24 | "@grafana/faro-web-tracing": "2.1.0", 25 | "@microsoft/signalr": "10.0.0", 26 | "moment": "2.30.1" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "7.28.5", 30 | "@babel/preset-env": "7.28.5", 31 | "@stylistic/eslint-plugin": "5.6.1", 32 | "@typescript-eslint/eslint-plugin": "8.50.0", 33 | "@typescript-eslint/parser": "8.50.0", 34 | "@vitest/coverage-v8": "4.0.16", 35 | "@vitest/eslint-plugin": "1.5.2", 36 | "@vitest/ui": "4.0.16", 37 | "css-loader": "7.1.2", 38 | "css-minimizer-webpack-plugin": "7.0.4", 39 | "eslint": "9.39.2", 40 | "eslint-config-prettier": "10.1.8", 41 | "globals": "16.5.0", 42 | "mini-css-extract-plugin": "2.9.4", 43 | "prettier": "3.7.4", 44 | "style-loader": "4.0.0", 45 | "stylelint": "16.26.1", 46 | "stylelint-config-standard": "39.0.1", 47 | "ts-loader": "9.5.4", 48 | "tsify": "5.0.4", 49 | "typescript": "5.9.3", 50 | "vitest": "4.0.16", 51 | "webpack": "5.104.0", 52 | "webpack-cli": "6.0.1", 53 | "webpack-remove-empty-scripts": "1.1.1" 54 | }, 55 | "prettier": { 56 | "arrowParens": "always", 57 | "bracketSpacing": true, 58 | "endOfLine": "auto", 59 | "printWidth": 140, 60 | "quoteProps": "consistent", 61 | "semi": true, 62 | "singleQuote": true, 63 | "tabWidth": 4, 64 | "trailingComma": "es5", 65 | "useTabs": false 66 | }, 67 | "stylelint": { 68 | "extends": [ 69 | "stylelint-config-standard" 70 | ] 71 | } 72 | } 73 | --------------------------------------------------------------------------------