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