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 StatusFailed = 2;
private const int StatusPassed = 3;
/// GET api/admin/v1/testcases — every test case with its
/// issue link, newest pass first. Optional ?passId= narrows to one
/// test pass and ?status= to one status.
///
/// Exists because the admin SPA speaks /api/admin/v1 exclusively and
/// the client-facing test-case routes are shaped for the in-game QA tab
/// (per-pass, no issue fields).
[HttpGet("api/admin/v1/testcases")]
public async Task ListCases(
[FromQuery] uint? passId, [FromQuery] int? status, CancellationToken ct)
{
var query = db.TestCases.AsQueryable();
if (passId is uint pass) query = query.Where(c => c.TestPassId == pass);
if (status is int s) query = query.Where(c => c.Status == s);
var cases = await query
.OrderByDescending(c => c.TestPassId)
.ThenBy(c => c.Key)
.Take(500)
.ToListAsync(ct);
return Ok(new
{
githubConfigured = github.IsConfigured,
repository = github.Repository,
cases = cases.Select(c => new
{
c.Id,
c.Key,
c.Title,
c.RoomName,
c.Status,
c.TestPassId,
issueUrl = c.JiraBugUrl,
issueNumber = GitHubIssueService.IssueNumberFromUrl(c.JiraBugUrl),
c.UpdatedAt,
}).ToList(),
});
}
/// 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")]
[HttpPost("api/admin/v1/testcases/{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")]
[HttpDelete("api/admin/v1/testcases/{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 Failed → Passed. A closed issue means
/// the bug is fixed, and the case that was failing on it therefore passes.
/// Issue REOPENED and the case is Passed → 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 one nobody has run yet, is
/// never rewritten underneath them.
[HttpPost("api/testcasemanagement/v1/issues/sync")]
[HttpPost("api/admin/v1/testcases/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