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(); 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}"; /// The issue body. Written for whoever picks the bug up, so it /// leads with what to do rather than with metadata. private static string BodyFor(TestCaseEntity tc) { var lines = new List(); 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("Filed from DorkNet QA test-case management."); return string.Join("\n", lines); } private static IEnumerable LabelsFor(TestCaseEntity tc) { yield return "qa"; foreach (var tag in ParseComments(tc.TagsJson)) yield return tag; } /// Both CommentsJson and TagsJson are JSON string arrays. A /// comment is stored as playerId|timestamp|text; only the text is /// worth putting in an issue. private static List ParseComments(string json) { try { var items = System.Text.Json.JsonSerializer.Deserialize>(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 []; } } }