From 7ce106ed1b2b0f18372f7a424e8046086b590ad2 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:13:31 +0300
Subject: [PATCH] feat: link QA test cases to GitHub issues
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
File a GitHub issue from a failing test case, and let the case follow the
issue afterwards.
POST api/testcasemanagement/v1/testcase/{id}/issue file + link
DELETE api/testcasemanagement/v1/testcase/{id}/issue unlink
POST api/testcasemanagement/v1/issues/sync reconcile now
plus TestCaseIssueReconciler, a background sweep on the same code path as the
manual endpoint so the two cannot drift. Admin-gated: these are not part of the
2023 client's surface, unlike the existing test-case routes which are open
because the watch gates that tab client-side.
The link lives in TestCaseEntity.JiraBugUrl — the field Rec Room's own tooling
used for the bug filed against a failing case. That avoids a migration, which
matters more here than the name: the Postgres path is EnsureCreated-only and
never replays migrations, so a new column has to be added twice or it is missing
in production. A leftover genuine Jira link is ignored rather than misparsed,
since the issue number is only read from a GitHub-shaped URL.
Filing is idempotent — a case already carrying a live issue returns it instead
of filing again, so sweeping a failing pass repeatedly does not spam duplicates.
Unlinking leaves the issue alone; closing someone's issue as a side effect of
tidying a QA link would be the wrong call.
Reconcile moves a case only between the two states it owns: closed+Failed goes
to NotYetTested, open+NotYetTested goes back to Failed. A closed issue is NOT
marked Passed — closing is a developer's claim that the fix landed, not a
tester's observation that the case passes, so the case goes back in the queue.
The first draft flipped any non-Failed case to Failed on an open issue, which
contradicted its own doc comment and yanked a case out from under a tester
mid-claim; the test for that caught it.
Everything degrades rather than fails when unconfigured: IsConfigured is false
without a token, endpoints answer 503 with the reason, and the reconciler logs
once and idles. GitHub is faked in the tests, which cover idempotency, both
state transitions, the mid-claim case, and the unconfigured path.
---
.../TestCaseGitHubIssueTests.cs | 250 ++++++++++++++++++
.../TestCaseIssuesController.cs | 245 +++++++++++++++++
DorkNet.Server/Services/GitHubIssueService.cs | 182 +++++++++++++
.../Services/TestCaseIssueReconciler.cs | 89 +++++++
.../Startup/ServiceCollectionExtensions.cs | 7 +
docs/admin.md | 53 ++++
6 files changed, 826 insertions(+)
create mode 100644 DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs
create mode 100644 DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
create mode 100644 DorkNet.Server/Services/GitHubIssueService.cs
create mode 100644 DorkNet.Server/Services/TestCaseIssueReconciler.cs
diff --git a/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs b/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs
new file mode 100644
index 0000000..eddcdb5
--- /dev/null
+++ b/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs
@@ -0,0 +1,250 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// QA test cases linked to GitHub issues.
+///
+/// The link lives in — the field Rec
+/// Room's own tooling used for the bug filed against a failing case — so no
+/// column is added and the Postgres "migrations never replay" trap is avoided
+/// entirely.
+///
+/// GitHub itself is faked. These tests are about our behaviour around it:
+/// filing once rather than per sweep, following issue state in the one
+/// direction that is safe, and degrading rather than 500ing when no token is
+/// configured.
+///
+public sealed class TestCaseGitHubIssueTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
+
+ private const int StatusNotYetTested = 0;
+ private const int StatusClaimed = 1;
+ private const int StatusFailed = 2;
+
+ [Fact]
+ public async Task Filing_is_idempotent_and_stores_the_issue_url()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ var firstBody = await first.Content.ReadAsStringAsync();
+ Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
+ Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
+
+ // The link must be persisted, and in the field the admin UI reads.
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
+ Assert.Contains("/issues/", tc.JiraBugUrl);
+ }
+
+ // Re-running a sweep over a failing pass must not file the bug again.
+ // Measured as a DELTA rather than an absolute count: the fake is shared
+ // with whatever else the server does during the test, and the property
+ // under test is "this call filed nothing", not "nothing was ever filed".
+ var filedBefore = github.Created;
+ using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ var secondBody = await second.Content.ReadAsStringAsync();
+ Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
+ Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
+ "filing twice created a duplicate issue");
+ Assert.Equal(filedBefore, github.Created);
+ }
+
+ [Fact]
+ public async Task A_closed_issue_sends_the_case_back_for_retesting_not_to_passed()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ github.CloseAll();
+ using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
+
+ // Closing an issue is a developer saying the fix landed — not a tester
+ // saying the case passes. It goes back in the queue, not to Passed.
+ Assert.Equal(StatusNotYetTested, tc.Status);
+ }
+
+ [Fact]
+ public async Task A_reopened_issue_marks_the_case_failed_again()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ github.CloseAll();
+ using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(closeSync.IsSuccessStatusCode);
+
+ github.ReopenAll();
+ using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(reopenSync.IsSuccessStatusCode);
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
+ }
+
+ [Fact]
+ public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ // Someone picks the case up while the issue is still open.
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
+ await db.SaveChangesAsync();
+ }
+
+ using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(sync.IsSuccessStatusCode);
+
+ await using var check = factory.Services.CreateAsyncScope();
+ var db2 = check.ServiceProvider.GetRequiredService();
+ var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
+
+ // An open issue against a claimed case says nothing new; flipping it to
+ // Failed would yank it out from under whoever is testing it.
+ Assert.Equal(StatusClaimed, tc.Status);
+ }
+
+ [Fact]
+ public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
+ {
+ // The default fixture has no GitHub configuration, which is what an
+ // ordinary deployment looks like.
+ using var client = await AdminClientAsync(_factory);
+ var caseId = await SeedCaseAsync(_factory, StatusFailed);
+
+ using var response = await client.PostAsync(
+ $"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ }
+
+ private WebApplicationFactory WithGitHub(IGitHubIssues github) =>
+ _factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(github);
+ }));
+
+ private async Task AdminClientAsync(WebApplicationFactory factory)
+ {
+ var client = factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
+ await db.SaveChangesAsync();
+ }
+
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+
+ private static async Task SeedCaseAsync(WebApplicationFactory factory, int status)
+ {
+ var id = $"tc-{Guid.NewGuid():N}";
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.TestCases.Add(new TestCaseEntity
+ {
+ Id = id,
+ Key = $"QA-{Random.Shared.Next(1000, 9999)}",
+ Title = "Dorm fails to load after cloning a room",
+ Description = "Clone RecCenter, then return to the dorm.",
+ RoomName = "Dorm",
+ Status = status,
+ TagsJson = """["dorm","regression"]""",
+ CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
+ });
+ await db.SaveChangesAsync();
+ return id;
+ }
+
+ /// An in-memory GitHub. Records what was filed so the tests can
+ /// assert we did not file twice, and lets them flip issue state.
+ private sealed class FakeGitHub : IGitHubIssues
+ {
+ private readonly Dictionary _issues = [];
+ private int _next = 1;
+
+ public bool IsConfigured => true;
+
+ public string Repository => "DorkSquadRR/DorkNet";
+
+ public int Created { get; private set; }
+
+ public Task CreateAsync(
+ string title, string body, IEnumerable labels, CancellationToken ct = default)
+ {
+ var number = _next++;
+ var issue = new GitHubIssue(
+ number, $"https://github.com/{Repository}/issues/{number}", "open", title);
+ _issues[number] = issue;
+ Created++;
+ return Task.FromResult(issue);
+ }
+
+ public Task GetAsync(int number, CancellationToken ct = default) =>
+ Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
+
+ public Task CommentAsync(int number, string body, CancellationToken ct = default) =>
+ Task.FromResult(_issues.ContainsKey(number));
+
+ public void CloseAll() => SetState("closed");
+
+ public void ReopenAll() => SetState("open");
+
+ private void SetState(string state)
+ {
+ foreach (var number in _issues.Keys.ToList())
+ _issues[number] = _issues[number] with { State = state };
+ }
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
new file mode 100644
index 0000000..86dd060
--- /dev/null
+++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
@@ -0,0 +1,245 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using DorkNet.Server.Auth;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+
+namespace DorkNet.Server.Controllers.API.TestCaseManagement;
+
+///
+/// Links QA test cases to GitHub issues.
+///
+/// These routes are NOT part of the 2023 client's surface — the in-game
+/// QA tab never calls them. They exist for the admin tooling, so they are
+/// admin-gated, unlike the client-facing routes in
+/// which are deliberately open
+/// (the watch gates that tab client-side on the developer role).
+///
+/// The issue URL is stored in —
+/// the field Rec Room's own tooling used for exactly this, a link to the bug
+/// filed against a failing case. Reusing it means no schema change, and on
+/// this server that matters more than the name: the Postgres path is
+/// EnsureCreated-only and never replays migrations, so every new column has to
+/// be added twice or it is missing in production. A field that already exists
+/// everywhere has neither problem, and the admin UI already renders it.
+///
+[ApiController]
+[AdminOnly]
+public class TestCaseIssuesController(
+ DorkNetDbContext db,
+ IGitHubIssues github,
+ ILogger log) : ControllerBase
+{
+ /// Test-case status values, matching the client's
+ /// TestCaseStatus enum.
+ private const int StatusNotYetTested = 0;
+ private const int StatusFailed = 2;
+
+ /// POST api/testcasemanagement/v1/testcase/{id}/issue —
+ /// file a GitHub issue for a test case and link it.
+ ///
+ /// Idempotent: a case that already carries a live issue link returns that
+ /// issue rather than filing a duplicate. Re-running a sweep over a failing
+ /// pass is therefore safe, which is the whole point — the alternative is a
+ /// tester filing the same bug on every run.
+ [HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")]
+ public async Task CreateIssue(string id, CancellationToken ct)
+ {
+ if (!github.IsConfigured)
+ return Problem(
+ "GitHub issue linking is not configured. Set GitHub:Token and GitHub:Repository.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+
+ var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct);
+ if (tc is null) return NotFound();
+
+ if (GitHubIssueService.IssueNumberFromUrl(tc.JiraBugUrl) is int existingNumber)
+ {
+ var existing = await github.GetAsync(existingNumber, ct);
+ // A link to an issue that no longer exists (deleted, or the repo
+ // changed) must not block filing a new one.
+ if (existing is not null)
+ return Ok(Describe(tc, existing, created: false));
+ }
+
+ var issue = await github.CreateAsync(TitleFor(tc), BodyFor(tc), LabelsFor(tc), ct);
+ if (issue is null)
+ return Problem("GitHub rejected the issue.", statusCode: StatusCodes.Status502BadGateway);
+
+ tc.JiraBugUrl = issue.Url;
+ tc.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(ct);
+
+ log.LogInformation(
+ "[testcase-issue] filed {Repo}#{Number} for case {Key} ({Id})",
+ github.Repository, issue.Number, tc.Key, tc.Id);
+ return Ok(Describe(tc, issue, created: true));
+ }
+
+ /// DELETE api/testcasemanagement/v1/testcase/{id}/issue —
+ /// unlink, leaving the issue itself alone. Closing someone's issue as a
+ /// side effect of tidying a QA link would be the wrong call.
+ [HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")]
+ public async Task UnlinkIssue(string id, CancellationToken ct)
+ {
+ var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct);
+ if (tc is null) return NotFound();
+
+ var previous = tc.JiraBugUrl;
+ tc.JiraBugUrl = string.Empty;
+ tc.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(ct);
+
+ log.LogInformation("[testcase-issue] unlinked {Url} from case {Key}", previous, tc.Key);
+ return Ok(new { tc.Id, tc.Key, unlinked = previous });
+ }
+
+ /// POST api/testcasemanagement/v1/issues/sync — reconcile
+ /// every linked case against its issue.
+ ///
+ /// The state a test case should be in follows the issue, in one direction
+ /// only:
+ ///
+ ///
+ /// Issue CLOSED and the case is still Failed → back to NotYetTested.
+ /// The bug is fixed, so the case is due for another run. It is deliberately
+ /// NOT marked Passed: closing an issue is a developer's claim that the fix
+ /// landed, not a tester's observation that the case passes.
+ /// Issue REOPENED and the case is NotYetTested → back to Failed.
+ /// The bug came back, so the close transition above is undone.
+ ///
+ ///
+ /// Nothing else moves. Only these two states are the reconciler's to
+ /// change, so a case a tester has Claimed — or marked Passed after
+ /// verifying the fix — is never dragged back underneath them.
+ [HttpPost("api/testcasemanagement/v1/issues/sync")]
+ public async Task Sync(CancellationToken ct)
+ {
+ if (!github.IsConfigured)
+ return Problem(
+ "GitHub issue linking is not configured. Set GitHub:Token and GitHub:Repository.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+
+ var changed = await ReconcileAsync(db, github, log, ct);
+ return Ok(new { reconciled = changed.Count, changes = changed });
+ }
+
+ /// Shared by the endpoint and the background reconciler so the two
+ /// can never drift apart.
+ internal static async Task> ReconcileAsync(
+ DorkNetDbContext db, IGitHubIssues github, ILogger log, CancellationToken ct)
+ {
+ var linked = await db.TestCases
+ .Where(c => c.JiraBugUrl != string.Empty)
+ .ToListAsync(ct);
+
+ var changes = new List