diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 5cc1e52..db9a7b3 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -10,6 +10,21 @@ jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 # Or your desired PostgreSQL version + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: labeller + ports: + - 5432:5432 # Expose the PostgreSQL port + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v2 - name: Setup .NET diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5b5717e..26e999c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,14 +32,9 @@ jobs: with: dotnet-version: '9.0.x' - - name: Install NativeAOT Dependencies + - name: Publish app run: | - sudo apt-get update - sudo apt-get install -y clang zlib1g-dev - - - name: Publish NativeAOT app - run: | - dotnet publish ./SS14.Labeller -c Release -r ${{ matrix.rid }} --self-contained true /p:PublishAot=true -o publish + dotnet publish ./SS14.Labeller -c Release -r ${{ matrix.rid }} --self-contained true -o publish - name: Prepare release zip run: | diff --git a/Dockerfile b/Dockerfile index ae96727..395cc26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,9 +18,9 @@ RUN echo "Building for platform: $TARGETPLATFORM" \ "linux/arm64") export RID=linux-arm64 ;; \ *) echo "Unsupported TARGETPLATFORM: $TARGETPLATFORM" && exit 1 ;; \ esac \ - && dotnet publish -c Release -r $RID --self-contained true /p:PublishAot=true -o /app + && dotnet publish -c Release -r $RID -o /app -FROM debian:bookworm-slim AS final +FROM mcr.microsoft.com/dotnet/aspnet:9.0.10-bookworm-slim AS final WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/README.md b/README.md index 4e54dc0..1b1a945 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,21 @@ Simple ASP.NET NativeAOT application for labelling our [content repository](https://github.com/space-wizards/space-station-14) -## Usage +## Features + +Main features of application are related to reaction to github events: +* Event of issue creation will lead to issue getting marked as ```S: Untriaged``` label +* Event of leaving review under pull request will lead to marking it with ```S: Approved``` or ```S: Awaiting Changes``` labels +* Events, related to pull request support following labels: + * marked with ```size/XS```, ```size/S```, ```size/M```, ```size/L``` or ```size/XL``` for size (depending on a total lines changed in PR) + * marked with ```Changes: Audio```, ```Changes: Map```, ```Changes: NoCSharp```, ```Changes: Shaders```, ```Changes: Sprites```, ```Changes: Ui``` labels based on extensions and routes of files that were affected by PR + * marked ```Branch: Stable``` or ```Branch: Staging``` if PR is targeting specific branch + * marked with ```S: Untriaged``` on creation + * marked ```S: Approved``` if created by maintainer + * can create discourse thread when PR is labelled with ```S: Undergoing Discussion``` by gh users (will post link to discussion in comment of PR) + * marks with either ```S: Needs Review``` or ```S: Awaiting Changes``` depending on review state that maintainers leave on PR (set to ```S: Needs Review``` on opening) + +## Usage Create the a file called appsettings.json like so: ```json @@ -37,12 +51,12 @@ To set the port, use the `ASPNETCORE_URLS` environment variable, e.g. `ASPNETCOR *DiscussionCategoryId*: What category to send new discussion Topics in. You can get this by opening the Topic in your browser and the number in the URL is the category ID.\ *Url*: The Forum URL. Must end with a trailing slash. -## Building +## Building and setting up hooks -To build the application, use the following command: +To build application for release and deployment, use the following command: ```bash -dotnet publish ./SS14.Labeller -c Release -r --self-contained true /p:PublishAot=true +dotnet publish ./SS14.Labeller -c Release -r --self-contained true ``` Running the application is just like any other executable. On Unix systems, you may need to set the executable bit on the binary. @@ -54,17 +68,27 @@ The token must have the `Issues` and `Pull requests` scopes enabled for read and ## Testing and debug +To run application locally you can launch run it as any other dotnet application. To set up its dependencies (database) locally you can run docker-compose: +``` +docker compose -f docker-compose-debug.yml up -d +``` +This will run local postgres to which labeller will try attach upon launching and when running integration tests. + +### Forwarding github events for local debugging You will need set up proxy for messages from GitHub to your local machine. For that you can use https://smee.io 1. Install Smee cli using npm (if you don't have it - follow those instructions [here](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm))\ ``` npm install --global smee-client ``` 2. Visit https://smee.io, click 'Start a new channel' and copy the link that will be generated. -3. In your console use Smee cli to start proxy forwarding to your local machine\ -```smee -u https://smee.io/{place-you-channel-code-here} -t http://127.0.0.1:5000/webhook``` +3. In your console use Smee cli to start proxy forwarding to your local machine: ``` smee -u https://smee.io/{place-you-channel-code-here} -t http://127.0.0.1:5000/webhook ``` + Upon launching, it will output line like + + ``` Forwarding https://smee.io/{place-you-channel-code-here} to http://127.0.0.1:5000/webhook ``` + That means that every message it receives, including * Its payload * Its headers diff --git a/SS14.Labeller.Tests/CustomWebApplicationFactory.cs b/SS14.Labeller.Tests/CustomWebApplicationFactory.cs index 02c0eb7..74fe263 100644 --- a/SS14.Labeller.Tests/CustomWebApplicationFactory.cs +++ b/SS14.Labeller.Tests/CustomWebApplicationFactory.cs @@ -1,17 +1,16 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using NSubstitute; -using SS14.Labeller.Database; using SS14.Labeller.DiscourseApi; using SS14.Labeller.GitHubApi; using SS14.Labeller.Repository; +using SS14.Labeller.Tests.IntegrationTests; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace SS14.Labeller.Tests; @@ -35,11 +34,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) sp.Replace(new ServiceDescriptor(typeof(IGitHubApiClient), GitHubApiClient)); sp.Replace(new ServiceDescriptor(typeof(IDiscourseClient), DiscourseClient)); sp.Replace(new ServiceDescriptor(typeof(IDiscourseTopicsRepository), TopicsRepository)); - var hostedServiceDescriptor = sp.First(d => - d.ServiceType == typeof(IHostedService) && - d.ImplementationType == typeof(DatabaseMigrationApplyingBackgroundService)); // Replace YourHostedService with the actual type - - sp.Remove(hostedServiceDescriptor); }).ConfigureAppConfiguration((context, configurationBuilder) => { configurationBuilder.AddInMemoryCollection(new Dictionary @@ -48,7 +42,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { "Discourse:Username", "aw" }, { "Discourse:DiscussionCategoryId", "42" }, { "Discourse:Url", "http://wa.wa" }, - { "GitHub:WebhookSecret", IntegrationTests.HookSecret }, + { "GitHub:WebhookSecret", HandlersTests.HookSecret }, { "GitHub:Token", "test-test" }, }); }); diff --git a/SS14.Labeller.Tests/GitHubApi/GithubRetryHandlerTests.cs b/SS14.Labeller.Tests/GitHubApi/GithubRetryHandlerTests.cs index 9ee15ab..cfdced9 100644 --- a/SS14.Labeller.Tests/GitHubApi/GithubRetryHandlerTests.cs +++ b/SS14.Labeller.Tests/GitHubApi/GithubRetryHandlerTests.cs @@ -39,7 +39,7 @@ public void SendAsync_SuccessfulRequest() _mockInnerHandler.Send(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new HttpResponseMessage { StatusCode = HttpStatusCode.OK })); - var handler = new GithubRetryHandler(_mockInnerHandler, _config, _logger); + var handler = new GithubRetryHandler(_config, _logger) { InnerHandler = _mockInnerHandler }; var httpClient = new HttpClient(handler); // Act @@ -55,11 +55,11 @@ public void SendAsync_NetworkErrorRetries() // Arrange _mockInnerHandler.Send(Arg.Any(), Arg.Any()) .Returns( - _=> throw new HttpRequestException(HttpRequestError.ConnectionError), - _=> Task.FromResult(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }) + _ => throw new HttpRequestException(HttpRequestError.ConnectionError), + _ => Task.FromResult(new HttpResponseMessage { StatusCode = HttpStatusCode.OK }) ); - var handler = new GithubRetryHandler(_mockInnerHandler, _config, _logger); + var handler = new GithubRetryHandler(_config, _logger) { InnerHandler = _mockInnerHandler }; var httpClient = new HttpClient(handler); // Act @@ -87,7 +87,7 @@ public void SendAsync_CalculateNextRequestTimeWithRateLimits() _mockInnerHandler.Send(Arg.Any(), Arg.Any()) .Returns(response1, response2); - var handler = new GithubRetryHandler(_mockInnerHandler, _config, _logger); + var handler = new GithubRetryHandler(_config, _logger) { InnerHandler = _mockInnerHandler }; var httpClient = new HttpClient(handler); // Act @@ -108,7 +108,7 @@ public void SendAsync_MaxRetryExceeded() _gitHubConfig.MaxRetryAttempt = 2; - var handler = new GithubRetryHandler(_mockInnerHandler, _config, _logger); + var handler = new GithubRetryHandler(_config, _logger) { InnerHandler = _mockInnerHandler }; var httpClient = new HttpClient(handler); // Act & Assert diff --git a/SS14.Labeller.Tests/IntegrationTests.Issue.cs b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.Issue.cs similarity index 94% rename from SS14.Labeller.Tests/IntegrationTests.Issue.cs rename to SS14.Labeller.Tests/IntegrationTests/HandlersTests.Issue.cs index 74de488..9761749 100644 --- a/SS14.Labeller.Tests/IntegrationTests.Issue.cs +++ b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.Issue.cs @@ -1,14 +1,14 @@ -using NSubstitute; -using NUnit.Framework; -using SS14.Labeller.Models; -using System.Net; +using System.Net; using System.Threading; using System.Threading.Tasks; +using NSubstitute; +using NUnit.Framework; using SS14.Labeller.Labelling.Labels; +using SS14.Labeller.Models; -namespace SS14.Labeller.Tests; +namespace SS14.Labeller.Tests.IntegrationTests; -public partial class IntegrationTests +public partial class HandlersTests { [Test] public async Task Issue_Created_AddedUntriagedLabel() diff --git a/SS14.Labeller.Tests/IntegrationTests.PullRequest.cs b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequest.cs similarity index 99% rename from SS14.Labeller.Tests/IntegrationTests.PullRequest.cs rename to SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequest.cs index 0576b00..e1940d4 100644 --- a/SS14.Labeller.Tests/IntegrationTests.PullRequest.cs +++ b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequest.cs @@ -1,14 +1,14 @@ -using NSubstitute; -using NUnit.Framework; -using SS14.Labeller.Models; -using System.Net; +using System.Net; using System.Threading; using System.Threading.Tasks; +using NSubstitute; +using NUnit.Framework; using SS14.Labeller.Labelling.Labels; +using SS14.Labeller.Models; -namespace SS14.Labeller.Tests; +namespace SS14.Labeller.Tests.IntegrationTests; -public partial class IntegrationTests +public partial class HandlersTests { [Test] public async Task PullRequest() diff --git a/SS14.Labeller.Tests/IntegrationTests.PullRequestReview.cs b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequestReview.cs similarity index 98% rename from SS14.Labeller.Tests/IntegrationTests.PullRequestReview.cs rename to SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequestReview.cs index ffeb1bc..ff1111b 100644 --- a/SS14.Labeller.Tests/IntegrationTests.PullRequestReview.cs +++ b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.PullRequestReview.cs @@ -1,14 +1,14 @@ -using NSubstitute; -using NUnit.Framework; -using SS14.Labeller.Models; -using System.Net; -using System.Threading.Tasks; +using System.Net; using System.Threading; +using System.Threading.Tasks; +using NSubstitute; +using NUnit.Framework; using SS14.Labeller.Labelling.Labels; +using SS14.Labeller.Models; -namespace SS14.Labeller.Tests; +namespace SS14.Labeller.Tests.IntegrationTests; -public partial class IntegrationTests +public partial class HandlersTests { [Test] diff --git a/SS14.Labeller.Tests/IntegrationTests.cs b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.cs similarity index 95% rename from SS14.Labeller.Tests/IntegrationTests.cs rename to SS14.Labeller.Tests/IntegrationTests/HandlersTests.cs index 0a2ac90..b07e1a7 100644 --- a/SS14.Labeller.Tests/IntegrationTests.cs +++ b/SS14.Labeller.Tests/IntegrationTests/HandlersTests.cs @@ -7,10 +7,10 @@ using NUnit.Framework; using SS14.Labeller.Helpers; -namespace SS14.Labeller.Tests; +namespace SS14.Labeller.Tests.IntegrationTests; [ExcludeFromCodeCoverage] -public partial class IntegrationTests +public partial class HandlersTests { public const string HookSecret = "asdasdasdasdasdasdasdadsadad"; diff --git a/SS14.Labeller.Tests/IntegrationTests/Repository/DiscourseTopicsRepositoryTests.cs b/SS14.Labeller.Tests/IntegrationTests/Repository/DiscourseTopicsRepositoryTests.cs new file mode 100644 index 0000000..61d4f12 --- /dev/null +++ b/SS14.Labeller.Tests/IntegrationTests/Repository/DiscourseTopicsRepositoryTests.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using Dapper; +using Microsoft.Extensions.Configuration; +using Npgsql; +using NUnit.Framework; +using SS14.Labeller.Repository; + +namespace SS14.Labeller.Tests.IntegrationTests.Repository; + +public class DiscourseTopicsRepositoryTests +{ + private IDiscourseTopicsRepository _repository; + + [SetUp] + public void Setup() + { + _repository = new DiscourseTopicsRepository(TestSetup.Configuration); + + CleanUpDb(); + } + + [Test] + public async Task HasTopic_NoSuchParamsCombination_IsFalse() + { + // Arrange + + // Act + var actual = await _repository.HasTopic("some-random-owner", "some-random-name", 54353, default); + + // Assert + Assert.That(actual, Is.False); + } + + + [Test] + public async Task Add_DoesNotExist_IsAdded() + { + // Arrange + const string owner = "some-random-owner"; + const string repoName = "some-random-name"; + const int issueNumber = 54353; + var before = await _repository.HasTopic(owner, repoName, issueNumber, default); + + // Act + await _repository.Add(owner, repoName, issueNumber, 1231, default); + + // Assert + var after = await _repository.HasTopic(owner, repoName, issueNumber, default); + + Assert.That(before, Is.False); + Assert.That(after, Is.True); + } + + [TearDown] + public void TearDown() + { + CleanUpDb(); + } + + private static void CleanUpDb() + { + var connectionString = TestSetup.Configuration.GetConnectionString("Default"); + using var con = new NpgsqlConnection(connectionString); + con.Open(); + con.Execute("TRUNCATE TABLE discourse.discussions"); + } +} diff --git a/SS14.Labeller.Tests/TestSetup.cs b/SS14.Labeller.Tests/TestSetup.cs new file mode 100644 index 0000000..ee0a315 --- /dev/null +++ b/SS14.Labeller.Tests/TestSetup.cs @@ -0,0 +1,21 @@ +using System.IO; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; + +namespace SS14.Labeller.Tests; + +[SetUpFixture] +public class TestSetup +{ + public static IConfiguration Configuration { get; private set; } + + [OneTimeSetUp] + public void RunBeforeAnyTests() + { + Configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + } +} \ No newline at end of file diff --git a/SS14.Labeller.sln b/SS14.Labeller.sln index 736dd99..8defc13 100644 --- a/SS14.Labeller.sln +++ b/SS14.Labeller.sln @@ -1,12 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.13.35818.85 d17.13 +VisualStudioVersion = 17.13.35818.85 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SS14.Labeller", "SS14.Labeller\SS14.Labeller.csproj", "{23A01977-7FE4-491F-BBE8-E8C412D8D753}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject + docker-compose-debug.yml = docker-compose-debug.yml + docker-compose.yml = docker-compose.yml + Dockerfile = Dockerfile README.md = README.md EndProjectSection EndProject diff --git a/SS14.Labeller/Database/CustomDbContext.cs b/SS14.Labeller/Database/CustomDbContext.cs new file mode 100644 index 0000000..e3ba1a5 --- /dev/null +++ b/SS14.Labeller/Database/CustomDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace SS14.Labeller.Database; + +/// Custom db-context that can be used in reusable way. Consumes all configurations that DI Container will pass. +public class CustomDbContext(DbContextOptions options, IEnumerable configurations) + : DbContext(options) +{ + private readonly IEnumerable _configurations = configurations ?? throw new ArgumentNullException(nameof(configurations)); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + foreach (var config in _configurations) + { + config.Apply(modelBuilder); + } + } +} \ No newline at end of file diff --git a/SS14.Labeller/Database/DatabaseMigration.cs b/SS14.Labeller/Database/DatabaseMigration.cs new file mode 100644 index 0000000..430885d --- /dev/null +++ b/SS14.Labeller/Database/DatabaseMigration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace SS14.Labeller.Database; + +public sealed class DatabaseMigration +{ + public static void MigrateDatabase(IServiceProvider sp) + { + using var scope = sp.CreateScope(); + // Put the database update into a scope to ensure + // that all resources will be disposed. + UpdateDatabase(scope.ServiceProvider); + } + + /// Update the database + private static void UpdateDatabase(IServiceProvider serviceProvider) + { + var contextFactory = serviceProvider.GetRequiredService>(); + using var context = contextFactory.CreateDbContext(); + context.Database.Migrate(); + } +} diff --git a/SS14.Labeller/Database/DatabaseMigrationApplyingBackgroundService.cs b/SS14.Labeller/Database/DatabaseMigrationApplyingBackgroundService.cs deleted file mode 100644 index 885560c..0000000 --- a/SS14.Labeller/Database/DatabaseMigrationApplyingBackgroundService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace SS14.Labeller.Database; - -public sealed class DatabaseMigrationApplyingBackgroundService(ILogger logger, IConfiguration configuration) - : BackgroundService -{ - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - var connectionString = configuration.GetConnectionString("Default") - ?? "Data Source=Application.db"; - await using var con = new SqliteConnection(connectionString); - con.Open(); - Migrator.Migrate(con, "SS14.Labeller.Database.Migrations", logger); - } -} \ No newline at end of file diff --git a/SS14.Labeller/Database/DiscourseEntitiesContextConfiguration.cs b/SS14.Labeller/Database/DiscourseEntitiesContextConfiguration.cs new file mode 100644 index 0000000..4914970 --- /dev/null +++ b/SS14.Labeller/Database/DiscourseEntitiesContextConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using SS14.Labeller.Database.Entities; + +namespace SS14.Labeller.Database; + +public class DiscourseEntitiesContextConfiguration : IContextConfiguration +{ + public const string TableName = "discussions"; + + public const string SchemaName = "discourse"; + + /// + public void Apply(ModelBuilder modelBuilder) + { + var ent = modelBuilder.Entity() + .ToTable(TableName, SchemaName); + ent.HasIndex(x => x.RepoOwner); + ent.HasIndex(x => x.RepoName); + ent.HasIndex(x => x.IssueNumber); + } +} \ No newline at end of file diff --git a/SS14.Labeller/Database/Entities/DiscourseTopicEntity.cs b/SS14.Labeller/Database/Entities/DiscourseTopicEntity.cs new file mode 100644 index 0000000..1f21709 --- /dev/null +++ b/SS14.Labeller/Database/Entities/DiscourseTopicEntity.cs @@ -0,0 +1,12 @@ +namespace SS14.Labeller.Database.Entities; + +public class DiscourseTopicEntity : EntityBase +{ + public required string RepoOwner { get; set; } + + public required string RepoName{ get; set; } + + public required int IssueNumber{ get; set; } + + public required int TopicId { get; set; } +} \ No newline at end of file diff --git a/SS14.Labeller/Database/Entities/EntityBase.cs b/SS14.Labeller/Database/Entities/EntityBase.cs new file mode 100644 index 0000000..f74902a --- /dev/null +++ b/SS14.Labeller/Database/Entities/EntityBase.cs @@ -0,0 +1,6 @@ +namespace SS14.Labeller.Database.Entities; + +public class EntityBase +{ + public int Id { get; set; } +} \ No newline at end of file diff --git a/SS14.Labeller/Database/IContextConfiguration.cs b/SS14.Labeller/Database/IContextConfiguration.cs new file mode 100644 index 0000000..c7be526 --- /dev/null +++ b/SS14.Labeller/Database/IContextConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace SS14.Labeller.Database; + +/// +/// Marker for types that can configure DbContext. Usually application will be one main DbContext, +/// for which every available implementation will be executed. +/// Interface that will help to apply all ef model configuration without interacting with one certain db-context. +/// +public interface IContextConfiguration +{ + /// Applies configuration to model. + void Apply(ModelBuilder modelBuilder); +} \ No newline at end of file diff --git a/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.Designer.cs b/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.Designer.cs new file mode 100644 index 0000000..10a4832 --- /dev/null +++ b/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.Designer.cs @@ -0,0 +1,71 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SS14.Labeller.Database; + +#nullable disable + +namespace SS14.Labeller.Database.Migrations +{ + [DbContext(typeof(CustomDbContext))] + [Migration("20250929183921_CreateTableDiscourseTopics")] + partial class CreateTableDiscourseTopics + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SS14.Labeller.Database.Entities.DiscourseTopicEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IssueNumber") + .HasColumnType("integer") + .HasColumnName("issue_number"); + + b.Property("RepoName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repo_name"); + + b.Property("RepoOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repo_owner"); + + b.Property("TopicId") + .HasColumnType("integer") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_discussions"); + + b.HasIndex("IssueNumber") + .HasDatabaseName("ix_discussions_issue_number"); + + b.HasIndex("RepoName") + .HasDatabaseName("ix_discussions_repo_name"); + + b.HasIndex("RepoOwner") + .HasDatabaseName("ix_discussions_repo_owner"); + + b.ToTable("discussions", "discourse"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.cs b/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.cs new file mode 100644 index 0000000..7d99d81 --- /dev/null +++ b/SS14.Labeller/Database/Migrations/20250929183921_CreateTableDiscourseTopics.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SS14.Labeller.Database.Migrations +{ + /// + public partial class CreateTableDiscourseTopics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "discourse"); + + migrationBuilder.CreateTable( + name: "discussions", + schema: "discourse", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + repo_owner = table.Column(type: "text", nullable: false), + repo_name = table.Column(type: "text", nullable: false), + issue_number = table.Column(type: "integer", nullable: false), + topic_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_discussions", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_discussions_issue_number", + schema: "discourse", + table: "discussions", + column: "issue_number"); + + migrationBuilder.CreateIndex( + name: "ix_discussions_repo_name", + schema: "discourse", + table: "discussions", + column: "repo_name"); + + migrationBuilder.CreateIndex( + name: "ix_discussions_repo_owner", + schema: "discourse", + table: "discussions", + column: "repo_owner"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "discussions", + schema: "discourse"); + } + } +} diff --git a/SS14.Labeller/Database/Migrations/CustomDbContextModelSnapshot.cs b/SS14.Labeller/Database/Migrations/CustomDbContextModelSnapshot.cs new file mode 100644 index 0000000..10711c2 --- /dev/null +++ b/SS14.Labeller/Database/Migrations/CustomDbContextModelSnapshot.cs @@ -0,0 +1,68 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SS14.Labeller.Database; + +#nullable disable + +namespace SS14.Labeller.Database.Migrations +{ + [DbContext(typeof(CustomDbContext))] + partial class CustomDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SS14.Labeller.Database.Entities.DiscourseTopicEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IssueNumber") + .HasColumnType("integer") + .HasColumnName("issue_number"); + + b.Property("RepoName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repo_name"); + + b.Property("RepoOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repo_owner"); + + b.Property("TopicId") + .HasColumnType("integer") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_discussions"); + + b.HasIndex("IssueNumber") + .HasDatabaseName("ix_discussions_issue_number"); + + b.HasIndex("RepoName") + .HasDatabaseName("ix_discussions_repo_name"); + + b.HasIndex("RepoOwner") + .HasDatabaseName("ix_discussions_repo_owner"); + + b.ToTable("discussions", "discourse"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SS14.Labeller/Database/Migrations/Script0001_Init.sql b/SS14.Labeller/Database/Migrations/Script0001_Init.sql deleted file mode 100644 index c448df1..0000000 --- a/SS14.Labeller/Database/Migrations/Script0001_Init.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE Discussions( - DiscussionId INTEGER PRIMARY KEY, - RepoOwner TEXT NOT NULL, - RepoName TEXT NOT NULL, - IssueNumber INTEGER NOT NULL, - TopicId INTEGER NOT NULL -); - -CREATE INDEX Discussions_Owner ON Discussions(RepoOwner); -CREATE INDEX Discussions_Name ON Discussions(RepoName); -CREATE INDEX Discussions_Issue ON Discussions(IssueNumber); \ No newline at end of file diff --git a/SS14.Labeller/Database/Migrator.cs b/SS14.Labeller/Database/Migrator.cs deleted file mode 100644 index b46e2ae..0000000 --- a/SS14.Labeller/Database/Migrator.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Dapper; -using Microsoft.Data.Sqlite; - -namespace SS14.Labeller.Database; - -public static class Migrator -{ - public static bool Migrate(SqliteConnection connection, string prefix, ILogger logger) - { - logger.LogDebug("Migrating with prefix {Prefix}", prefix); - - using var transaction = connection.BeginTransaction(); - - connection.Execute(""" - CREATE TABLE IF NOT EXISTS SchemaVersions( - SchemaVersionID INTEGER PRIMARY KEY, - ScriptName TEXT NOT NULL, - Applied DATETIME NOT NULL - ); - """, - transaction: transaction); - - var appliedScripts = connection.Query( - "SELECT ScriptName FROM main.SchemaVersions", - transaction: transaction); - - // ReSharper disable once InvokeAsExtensionMethod - var scriptsToApply = MigrationFileScriptList(prefix).ExceptBy(appliedScripts, s => s.name).OrderBy(x => x.name); - - var success = true; - foreach (var (name, script) in scriptsToApply) - { - logger.LogInformation("Applying migration {Transaction}!", name); - transaction.Save(name); - - try - { - var code = script.Up(connection); - - connection.Execute(code, transaction: transaction); - - connection.Execute( - "INSERT INTO SchemaVersions(ScriptName, Applied) VALUES (@Script, datetime('now'))", - new { Script = name }, - transaction); - - transaction.Release(name); - } - catch (Exception e) - { - logger.LogError(e, "Exception during migration {Transaction}, rolling back...!", name); - transaction.Rollback(name); - success = false; - break; - } - } - - logger.LogInformation("Committing migrations"); - transaction.Commit(); - return success; - } - - private static IEnumerable<(string name, IMigrationScript)> MigrationFileScriptList(string prefix) - { - var assembly = typeof(Migrator).Assembly; - foreach (var resourceName in assembly.GetManifestResourceNames()) - { - if (!resourceName.EndsWith(".sql") || !resourceName.StartsWith(prefix)) - continue; - - var index = resourceName.LastIndexOf('.', resourceName.Length - 5, resourceName.Length - 4); - index += 1; - - var name = resourceName[(index + "Script".Length)..^4]; - - using var reader = new StreamReader(assembly.GetManifestResourceStream(resourceName)!); - var scriptContents = reader.ReadToEnd(); - yield return (name, new FileMigrationScript(scriptContents)); - } - } - - public interface IMigrationScript - { - string Up(SqliteConnection connection); - } - - private sealed class FileMigrationScript : IMigrationScript - { - private readonly string _code; - - public FileMigrationScript(string code) => _code = code; - - public string Up(SqliteConnection connection) => _code; - } -} \ No newline at end of file diff --git a/SS14.Labeller/DiscourseApi/DummyDiscourseClient.cs b/SS14.Labeller/DiscourseApi/DummyDiscourseClient.cs index 5ce1ff4..98babff 100644 --- a/SS14.Labeller/DiscourseApi/DummyDiscourseClient.cs +++ b/SS14.Labeller/DiscourseApi/DummyDiscourseClient.cs @@ -2,7 +2,9 @@ namespace SS14.Labeller.DiscourseApi; -public class DummyDiscourseClient : IDiscourseClient +#pragma warning disable CS9113 // Parameter is unread. +public class DummyDiscourseClient(HttpClient _) : IDiscourseClient +#pragma warning restore CS9113 // Parameter is unread. { public Task CreateTopic(int category, string body, string title, CancellationToken ct) => Task.FromResult(new DiscourseCreatedPost() diff --git a/SS14.Labeller/GitHubApi/GithubRetryHandler.cs b/SS14.Labeller/GitHubApi/GithubRetryHandler.cs index 0f9b248..d85db53 100644 --- a/SS14.Labeller/GitHubApi/GithubRetryHandler.cs +++ b/SS14.Labeller/GitHubApi/GithubRetryHandler.cs @@ -15,7 +15,7 @@ namespace SS14.Labeller.GitHubApi; ///
Rate limit information /// /// This was designed for the 2022-11-28 version of the API. -public sealed class GithubRetryHandler(HttpMessageHandler innerHandler, IOptionsMonitor githubConfig, ILogger logger) : DelegatingHandler(innerHandler) +public sealed class GithubRetryHandler(IOptionsMonitor githubConfig, ILogger logger) : DelegatingHandler { private const int MaxWaitSeconds = 32; diff --git a/SS14.Labeller/Models/PullRequestEvent.cs b/SS14.Labeller/Models/PullRequestEvent.cs index 31bb1f0..661b8e5 100644 --- a/SS14.Labeller/Models/PullRequestEvent.cs +++ b/SS14.Labeller/Models/PullRequestEvent.cs @@ -44,6 +44,7 @@ public enum PullRequestEventType Opened, ReviewRequested } + public class PullRequest { public int Number { get; set; } diff --git a/SS14.Labeller/Program.cs b/SS14.Labeller/Program.cs index 6eaa19a..da7474a 100644 --- a/SS14.Labeller/Program.cs +++ b/SS14.Labeller/Program.cs @@ -2,13 +2,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using SS14.Labeller.Configuration; +using SS14.Labeller.Database; using SS14.Labeller.Endpoints; using SS14.Labeller.Handlers; using SS14.Labeller.Middlewares; using SS14.Labeller.Models; -[module:DapperAot] - namespace SS14.Labeller; public class Program @@ -28,6 +27,8 @@ public static void Main(string[] args) var app = builder.Build(); + DatabaseMigration.MigrateDatabase(app.Services); + app.UseHttpLogging(); app.MapGet("/", () => Results.Ok("Nik is a cat!")); diff --git a/SS14.Labeller/Registry.cs b/SS14.Labeller/Registry.cs index 7757d5d..ff04414 100644 --- a/SS14.Labeller/Registry.cs +++ b/SS14.Labeller/Registry.cs @@ -7,6 +7,7 @@ using SS14.Labeller.Labelling; using SS14.Labeller.Repository; using System.Net.Http.Headers; +using Microsoft.EntityFrameworkCore; using Polly; using Polly.Extensions.Http; @@ -43,6 +44,7 @@ public static void RegisterDependencies(this IServiceCollection service, IConfig client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", githubConfig.Token); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); }).AddHttpMessageHandler(); + service.AddTransient(); var discourseStartupConfig = new DiscourseConfig(); configuration.Bind(DiscourseConfig.Name, discourseStartupConfig); @@ -74,12 +76,24 @@ public static void RegisterDependencies(this IServiceCollection service, IConfig service.AddSingleton(); - service.AddHostedService(); - service.AddSingleton>( sp => sp.GetServices() .ToDictionary(x => x.CanHandleType) ); + + + var connectionString = configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException( + "Failed to find 'Default' connection string " + + "from application configuration for database initialization." + ); + + service.AddPooledDbContextFactory( + optsBuilder => optsBuilder.UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention() + ); + + service.AddSingleton(); } private static IAsyncPolicy GetDiscourseRetryPolicy(IServiceProvider sp) diff --git a/SS14.Labeller/Repository/Commands/InsertDiscourseTopicCommand.cs b/SS14.Labeller/Repository/Commands/InsertDiscourseTopicCommand.cs index 4a46c72..a99d61c 100644 --- a/SS14.Labeller/Repository/Commands/InsertDiscourseTopicCommand.cs +++ b/SS14.Labeller/Repository/Commands/InsertDiscourseTopicCommand.cs @@ -20,7 +20,7 @@ public override async Task Execute(DbConnection connection, CancellationTok } private const string Sql = $""" - INSERT INTO Discussions (RepoOwner, RepoName, IssueNumber, TopicId) + INSERT INTO discourse.discussions (repo_owner, repo_name, issue_number, topic_id) VALUES (@{nameof(RepoOwner)}, @{nameof(RepoName)}, @{nameof(IssueNumber)}, @{nameof(TopicId)}); """; diff --git a/SS14.Labeller/Repository/DiscourseTopicsRepository.cs b/SS14.Labeller/Repository/DiscourseTopicsRepository.cs index 35204ef..4724358 100644 --- a/SS14.Labeller/Repository/DiscourseTopicsRepository.cs +++ b/SS14.Labeller/Repository/DiscourseTopicsRepository.cs @@ -19,7 +19,8 @@ public class DiscourseTopicsRepository(IConfiguration configuration) public async Task HasTopic(string repoOwner, string repoName, int issueNumber, CancellationToken ct) { - return (await FindTopicIdForDiscussion(repoOwner, repoName, issueNumber, ct)).HasValue; + var foundId = await FindTopicIdForDiscussion(repoOwner, repoName, issueNumber, ct); + return foundId.HasValue; } public async Task Add(string owner, string name, int issueNumber, int topicId, CancellationToken ct) diff --git a/SS14.Labeller/Repository/Queries/FindTopicQuery.cs b/SS14.Labeller/Repository/Queries/FindTopicQuery.cs index 2d8665a..5bbeabe 100644 --- a/SS14.Labeller/Repository/Queries/FindTopicQuery.cs +++ b/SS14.Labeller/Repository/Queries/FindTopicQuery.cs @@ -14,12 +14,12 @@ public class FindTopicQuery : DatabaseQueryBase public override async Task Query(DbConnection connection, CancellationToken ct) { var cd = GetCommand(ct); - return await connection.QueryFirstOrDefaultAsync(cd); + return await connection.QueryFirstOrDefaultAsync(cd); } private const string Sql = $""" - SELECT TopicId FROM Discussions - WHERE RepoOwner = @{nameof(RepoOwner)} AND RepoName = @{nameof(RepoName)} AND IssueNumber = @{nameof(IssueNumber)} + SELECT topic_id FROM discourse.discussions + WHERE repo_owner = @{nameof(RepoOwner)} AND repo_name = @{nameof(RepoName)} AND issue_number = @{nameof(IssueNumber)} """; /// diff --git a/SS14.Labeller/Repository/RepositoryBase.cs b/SS14.Labeller/Repository/RepositoryBase.cs index d636243..b40e53a 100644 --- a/SS14.Labeller/Repository/RepositoryBase.cs +++ b/SS14.Labeller/Repository/RepositoryBase.cs @@ -1,5 +1,5 @@ using System.Data.Common; -using Microsoft.Data.Sqlite; +using Npgsql; namespace SS14.Labeller.Repository; @@ -8,8 +8,11 @@ public class RepositoryBase(IConfiguration configuration) protected DbConnection OpenConnection() { var connectionString = configuration.GetConnectionString("Default") - ?? "Data Source=Application.db"; - var con = new SqliteConnection(connectionString); + ?? throw new InvalidOperationException( + "Failed to find 'Default' connection string " + + "from application configuration for repository." + ); ; + var con = new NpgsqlConnection(connectionString); con.Open(); return con; } diff --git a/SS14.Labeller/SS14.Labeller.csproj b/SS14.Labeller/SS14.Labeller.csproj index 932e3b2..b566643 100644 --- a/SS14.Labeller/SS14.Labeller.csproj +++ b/SS14.Labeller/SS14.Labeller.csproj @@ -4,22 +4,20 @@ enable enable true - true - true - $(InterceptorsPreviewNamespaces);Dapper.AOT true - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + + diff --git a/SS14.Labeller/appsettings.json b/SS14.Labeller/appsettings.json index b266ddb..5afedd4 100644 --- a/SS14.Labeller/appsettings.json +++ b/SS14.Labeller/appsettings.json @@ -1,5 +1,5 @@ { - "ConnectionStrings":{ - "Default": "Data Source=data/Application.db" + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=labeller;" } } \ No newline at end of file diff --git a/build/database-init/init.sql b/build/database-init/init.sql new file mode 100644 index 0000000..09c6531 --- /dev/null +++ b/build/database-init/init.sql @@ -0,0 +1 @@ + CREATE DATABASE labeller; \ No newline at end of file diff --git a/docker-compose-debug.yml b/docker-compose-debug.yml new file mode 100644 index 0000000..6ed1706 --- /dev/null +++ b/docker-compose-debug.yml @@ -0,0 +1,13 @@ +services: + postgres: + image: postgres:16-alpine + container_name: postgresql + restart: always + environment: + POSTGRES_DB: labeller + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - ./build/database-init/init.sql:/docker-entrypoint-initdb.d/init.sql