Files
DorkNet/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
T
Alexa 7ce106ed1b feat: link QA test cases to GitHub issues
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.
2026-07-28 17:13:31 +03:00

246 lines
10 KiB
C#

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;
/// <summary>
/// Links QA test cases to GitHub issues.
///
/// <para>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
/// <see cref="TestCaseManagementController"/> which are deliberately open
/// (the watch gates that tab client-side on the developer role).</para>
///
/// <para>The issue URL is stored in <see cref="TestCaseEntity.JiraBugUrl"/> —
/// 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.</para>
/// </summary>
[ApiController]
[AdminOnly]
public class TestCaseIssuesController(
DorkNetDbContext db,
IGitHubIssues github,
ILogger<TestCaseIssuesController> log) : ControllerBase
{
/// <summary>Test-case status values, matching the client's
/// <c>TestCaseStatus</c> enum.</summary>
private const int StatusNotYetTested = 0;
private const int StatusFailed = 2;
/// <summary>POST <c>api/testcasemanagement/v1/testcase/{id}/issue</c> —
/// 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.</summary>
[HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")]
public async Task<IActionResult> 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));
}
/// <summary>DELETE <c>api/testcasemanagement/v1/testcase/{id}/issue</c> —
/// unlink, leaving the issue itself alone. Closing someone's issue as a
/// side effect of tidying a QA link would be the wrong call.</summary>
[HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")]
public async Task<IActionResult> 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 });
}
/// <summary>POST <c>api/testcasemanagement/v1/issues/sync</c> — reconcile
/// every linked case against its issue.
///
/// The state a test case should be in follows the issue, in one direction
/// only:
///
/// <list type="bullet">
/// <item>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.</item>
/// <item>Issue REOPENED and the case is NotYetTested → back to Failed.
/// The bug came back, so the close transition above is undone.</item>
/// </list>
///
/// 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.</summary>
[HttpPost("api/testcasemanagement/v1/issues/sync")]
public async Task<IActionResult> 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 });
}
/// <summary>Shared by the endpoint and the background reconciler so the two
/// can never drift apart.</summary>
internal static async Task<List<object>> 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<object>();
foreach (var tc in linked)
{
if (ct.IsCancellationRequested) break;
if (GitHubIssueService.IssueNumberFromUrl(tc.JiraBugUrl) is not int number) continue;
var issue = await github.GetAsync(number, ct);
if (issue is null) continue;
var closed = issue.State.Equals("closed", StringComparison.OrdinalIgnoreCase);
// Only ever move a case between the two states this reconciler
// itself owns. Reopening undoes the close transition and nothing
// else: "open issue and the case isn't Failed" would also catch a
// case a tester has just Claimed, or one they marked Passed after
// verifying the fix, and drag it back to Failed underneath them.
int? next = (closed, tc.Status) switch
{
(true, StatusFailed) => StatusNotYetTested,
(false, StatusNotYetTested) => StatusFailed,
_ => null,
};
if (next is not int status) continue;
var from = tc.Status;
tc.Status = status;
tc.UpdatedAt = DateTime.UtcNow;
changes.Add(new { tc.Id, tc.Key, issue = number, from, to = status });
log.LogInformation(
"[testcase-issue] case {Key} status {From} -> {To} (issue #{Number} is {State})",
tc.Key, from, status, number, issue.State);
}
if (changes.Count > 0) await db.SaveChangesAsync(ct);
return changes;
}
private object Describe(TestCaseEntity tc, GitHubIssue issue, bool created) => new
{
tc.Id,
tc.Key,
tc.Title,
tc.Status,
issue = new { issue.Number, issue.Url, issue.State },
created,
};
private static string TitleFor(TestCaseEntity tc) =>
string.IsNullOrWhiteSpace(tc.Key) ? tc.Title : $"[{tc.Key}] {tc.Title}";
/// <summary>The issue body. Written for whoever picks the bug up, so it
/// leads with what to do rather than with metadata.</summary>
private static string BodyFor(TestCaseEntity tc)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(tc.Description))
{
lines.Add(tc.Description.Trim());
lines.Add("");
}
lines.Add("| | |");
lines.Add("| --- | --- |");
lines.Add($"| Test case | `{tc.Key}` |");
if (!string.IsNullOrWhiteSpace(tc.RoomName)) lines.Add($"| Room | {tc.RoomName} |");
if (tc.TestPassId is uint pass) lines.Add($"| Test pass | {pass} |");
if (!string.IsNullOrWhiteSpace(tc.JiraUrl)) lines.Add($"| Test case link | {tc.JiraUrl} |");
var comments = ParseComments(tc.CommentsJson);
if (comments.Count > 0)
{
lines.Add("");
lines.Add("### Tester notes");
foreach (var comment in comments) lines.Add($"- {comment}");
}
lines.Add("");
lines.Add("<sub>Filed from DorkNet QA test-case management.</sub>");
return string.Join("\n", lines);
}
private static IEnumerable<string> LabelsFor(TestCaseEntity tc)
{
yield return "qa";
foreach (var tag in ParseComments(tc.TagsJson)) yield return tag;
}
/// <summary>Both CommentsJson and TagsJson are JSON string arrays. A
/// comment is stored as <c>playerId|timestamp|text</c>; only the text is
/// worth putting in an issue.</summary>
private static List<string> ParseComments(string json)
{
try
{
var items = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];
return items
.Select(item =>
{
var parts = item.Split('|', 3);
return (parts.Length == 3 ? parts[2] : item).Trim();
})
.Where(text => text.Length > 0)
.ToList();
}
catch (System.Text.Json.JsonException)
{
return [];
}
}
}