using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace DorkNet.Server.Services;
/// One GitHub issue, reduced to what QA linking needs.
public sealed record GitHubIssue(int Number, string Url, string State, string Title);
///
/// Files and reads GitHub issues for the QA test-case tooling.
///
/// Deliberately narrow: create, read state, comment. Everything else a QA
/// workflow might want (labels beyond the fixed set, milestones, assignees) is
/// out of scope until something asks for it.
///
public interface IGitHubIssues
{
/// False when no token/repo is configured. Callers must check this
/// and degrade rather than throw — a server without GitHub credentials is a
/// normal deployment, not a broken one.
bool IsConfigured { get; }
/// owner/name, or empty when unconfigured.
string Repository { get; }
Task CreateAsync(string title, string body, IEnumerable labels, CancellationToken ct = default);
Task GetAsync(int number, CancellationToken ct = default);
Task CommentAsync(int number, string body, CancellationToken ct = default);
}
///
/// REST implementation over api.github.com.
///
/// Configured with GitHub:Token (a PAT with issues:write on the
/// target repo) and GitHub:Repository (owner/name). Both are
/// read once at construction; absent either, is
/// false and every call returns null rather than throwing, so QA endpoints
/// answer "not configured" instead of 500ing.
///
public sealed class GitHubIssueService : IGitHubIssues
{
private const string ApiRoot = "https://api.github.com";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _log;
private readonly string _token;
public GitHubIssueService(
IConfiguration config,
IHttpClientFactory httpClientFactory,
ILogger log)
{
_httpClientFactory = httpClientFactory;
_log = log;
_token = config["GitHub:Token"] ?? string.Empty;
Repository = (config["GitHub:Repository"] ?? string.Empty).Trim().Trim('/');
if (!IsConfigured)
{
_log.LogInformation(
"[github] no token/repository configured; issue linking is disabled");
}
else
{
_log.LogInformation("[github] issue linking enabled for {Repository}", Repository);
}
}
public bool IsConfigured => _token.Length > 0 && Repository.Contains('/');
public string Repository { get; }
public async Task CreateAsync(
string title, string body, IEnumerable labels, CancellationToken ct = default)
{
if (!IsConfigured) return null;
var payload = JsonSerializer.Serialize(new
{
title,
body,
labels = labels.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().ToArray(),
});
return await SendAsync(HttpMethod.Post, $"/repos/{Repository}/issues", payload, ct);
}
public Task GetAsync(int number, CancellationToken ct = default) =>
IsConfigured
? SendAsync(HttpMethod.Get, $"/repos/{Repository}/issues/{number}", null, ct)
: Task.FromResult(null);
public async Task CommentAsync(int number, string body, CancellationToken ct = default)
{
if (!IsConfigured) return false;
var payload = JsonSerializer.Serialize(new { body });
using var response = await SendRawAsync(
HttpMethod.Post, $"/repos/{Repository}/issues/{number}/comments", payload, ct);
return response is { IsSuccessStatusCode: true };
}
private async Task SendAsync(
HttpMethod method, string path, string? payload, CancellationToken ct)
{
using var response = await SendRawAsync(method, path, payload, ct);
if (response is null || !response.IsSuccessStatusCode) return null;
try
{
var json = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new GitHubIssue(
root.GetProperty("number").GetInt32(),
root.GetProperty("html_url").GetString() ?? string.Empty,
root.GetProperty("state").GetString() ?? "open",
root.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty);
}
catch (Exception ex)
{
_log.LogWarning(ex, "[github] could not read the issue response from {Method} {Path}", method, path);
return null;
}
}
private async Task SendRawAsync(
HttpMethod method, string path, string? payload, CancellationToken ct)
{
try
{
var client = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(method, ApiRoot + path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
// GitHub rejects requests without one.
request.Headers.UserAgent.ParseAdd("DorkNet-QA");
request.Headers.Add("X-GitHub-Api-Version", "2022-11-28");
if (payload is not null)
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
// A 404 on a GET is ordinary (deleted issue); anything else is
// worth seeing, but never fatal to the caller.
var level = method == HttpMethod.Get && response.StatusCode == HttpStatusCode.NotFound
? LogLevel.Debug
: LogLevel.Warning;
_log.Log(level, "[github] {Method} {Path} -> {Status}", method, path, (int)response.StatusCode);
}
return response;
}
catch (Exception ex)
{
_log.LogWarning(ex, "[github] {Method} {Path} failed", method, path);
return null;
}
}
/// Pull the issue number out of a stored issue URL.
///
/// The link lives in TestCaseEntity.JiraBugUrl — the field Rec
/// Room's own QA tooling used for the same purpose — so the only thing
/// identifying it as a GitHub issue is the URL shape. Returns null for
/// anything that isn't one, which is how a genuine Jira link left over
/// from the original data is ignored rather than misparsed.
public static int? IssueNumberFromUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var match = Regex.Match(url, @"github\.com/[^/]+/[^/]+/issues/(?\d+)",
RegexOptions.IgnoreCase);
return match.Success && int.TryParse(match.Groups["number"].Value, out var number)
? number
: null;
}
}