File: Scripts\Common\RealGitHubPRFixture.cs
Web Access
Project: src\tests\Aspire.Acquisition.Tests\Aspire.Acquisition.Tests.csproj (Aspire.Acquisition.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.TestUtilities;
using Xunit;
using Aspire.Templates.Tests;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// Fixture that discovers a suitable merged PR with required CLI artifacts for integration testing.
/// Uses GitHub CLI and parses JSON in C# to avoid shell quoting issues.
/// </summary>
public class RealGitHubPRFixture : IAsyncLifetime
{
    private readonly ITestOutputHelper _testOutput;
 
    /// <summary>
    /// PR number that has the required artifacts.
    /// </summary>
    public int PRNumber { get; private set; }
 
    /// <summary>
    /// Workflow run ID for the PR.
    /// </summary>
    public long RunId { get; private set; }
 
    /// <summary>
    /// Commit SHA for the PR.
    /// </summary>
    public string CommitSHA { get; private set; } = string.Empty;
 
    public RealGitHubPRFixture()
    {
        // Note: In xUnit fixtures, we can't get ITestOutputHelper in constructor
        // We'll create one for initialization
        _testOutput = new TestOutputHelperStub();
    }
 
    public async ValueTask InitializeAsync()
    {
        // Check if GH_TOKEN is available
        var ghToken = Environment.GetEnvironmentVariable("GH_TOKEN");
        if (string.IsNullOrWhiteSpace(ghToken))
        {
            _testOutput.WriteLine("GH_TOKEN environment variable not set. Integration tests will be skipped.");
            _testOutput.WriteLine("To run integration tests, set GH_TOKEN to a valid GitHub token.");
            // Don't throw - let individual tests handle the missing fixture data
            PRNumber = 0;
            RunId = 0;
            CommitSHA = string.Empty;
            return;
        }
 
        // Query recent merged PRs using gh CLI
        // Use microsoft/aspire to match the default ASPIRE_REPO in the scripts
        var stdoutJson = await ExecuteGhJsonAsync(
            "pr", "list",
            "--repo", "microsoft/aspire",
            "--state", "merged",
            "--limit", "20",
            "--json", "number,mergedAt,headRefOid"
        );
 
        // Parse JSON response in C# to avoid jq quoting issues
        var prs = JsonSerializer.Deserialize<List<GitHubPR>>(stdoutJson)
            ?? throw new InvalidOperationException("Failed to parse PR list JSON");
 
        // Try each PR to find one with required artifacts
        foreach (var pr in prs.OrderByDescending(p => p.MergedAt))
        {
            if (await TryFindRunWithArtifactsAsync(pr.Number, pr.HeadRefOid))
            {
                PRNumber = pr.Number;
                return;
            }
        }
 
        throw new InvalidOperationException(
            "Could not find a suitable merged PR with required CLI artifacts. " +
            "This may indicate a CI/build issue. Please check recent PRs manually.");
    }
 
    private async Task<bool> TryFindRunWithArtifactsAsync(int prNumber, string commitSha)
    {
        // Query workflow runs for this PR's commit
        string runsJson;
        try
        {
            runsJson = await ExecuteGhJsonAsync(
                "run", "list",
                "--repo", "microsoft/aspire",
                "--commit", commitSha,
                "--workflow", "ci.yml",
                "--status", "completed",
                "--limit", "5",
                "--json", "databaseId,conclusion"
            );
        }
        catch (Exception ex) when (ex is ToolCommandException or JsonException or InvalidOperationException)
        {
            _testOutput.WriteLine($"Skipping PR {prNumber}: {ex.GetType().Name}: {ex.Message}");
            return false;
        }
 
        var runs = JsonSerializer.Deserialize<List<GitHubWorkflowRun>>(runsJson);
        if (runs is null || runs.Count == 0)
        {
            return false;
        }
 
        // Find a successful run
        var successfulRun = runs.FirstOrDefault(r =>
            r.Conclusion?.Equals("success", StringComparison.OrdinalIgnoreCase) == true);
 
        if (successfulRun is null)
        {
            return false;
        }
 
        // Check if this run has required artifacts using the REST API with pagination
        // (gh run view --json artifacts is not supported; artifacts can span multiple pages)
        var artifactNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var page = 1;
        const int perPage = 100;
 
        while (true)
        {
            string artifactsJson;
            try
            {
                artifactsJson = await ExecuteGhJsonAsync(
                    "api",
                    $"repos/microsoft/aspire/actions/runs/{successfulRun.DatabaseId}/artifacts?per_page={perPage}&page={page}"
                );
            }
            catch (Exception ex) when (ex is ToolCommandException or JsonException or InvalidOperationException)
            {
                _testOutput.WriteLine($"Skipping artifact query for run {successfulRun.DatabaseId}: {ex.GetType().Name}: {ex.Message}");
                return false;
            }
 
            var artifactsResponse = JsonSerializer.Deserialize<ArtifactsResponse>(artifactsJson);
            if (artifactsResponse?.Artifacts is null || artifactsResponse.Artifacts.Count == 0)
            {
                break;
            }
 
            foreach (var artifact in artifactsResponse.Artifacts)
            {
                artifactNames.Add(artifact.Name);
            }
 
            if (artifactsResponse.Artifacts.Count < perPage)
            {
                break;
            }
 
            page++;
        }
 
        // Check for required artifacts
        var hasCliNativeArchives = artifactNames.Any(n => n.StartsWith("cli-native-", StringComparison.OrdinalIgnoreCase));
        var hasBuiltNugets = artifactNames.Contains("built-nugets");
        var hasBuiltNugetsRid = artifactNames.Any(n => n.StartsWith("built-nugets-for", StringComparison.OrdinalIgnoreCase));
 
        if (hasCliNativeArchives && hasBuiltNugets && hasBuiltNugetsRid)
        {
            RunId = successfulRun.DatabaseId;
            CommitSHA = commitSha;
            _testOutput.WriteLine($"Found suitable PR #{prNumber} with run {RunId}");
            return true;
        }
 
        return false;
    }
 
    /// <summary>
    /// Executes a gh CLI command and captures only stdout for JSON parsing,
    /// avoiding stderr pollution (upgrade notices, warnings) that would break deserialization.
    /// </summary>
    private async Task<string> ExecuteGhJsonAsync(params string[] args)
    {
        var stdoutLines = new List<string>();
        using var cmd = new ToolCommand("gh", _testOutput)
            .WithTimeout(TimeSpan.FromMinutes(2))
            .WithOutputDataReceived(line =>
            {
                if (line is not null)
                {
                    stdoutLines.Add(line);
                }
            });
 
        var result = await cmd.ExecuteAsync(args);
        result.EnsureSuccessful();
 
        return string.Join(Environment.NewLine, stdoutLines);
    }
 
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
 
    // Simple stub for initialization logging (ITestOutputHelper includes Write methods in xUnit v3)
    private sealed class TestOutputHelperStub : ITestOutputHelper
    {
        public string Output => string.Empty;
        public void Write(string message) => Console.Write(message);
        public void Write(string format, params object[] args) => Console.Write(format, args);
        public void WriteLine(string message) => Console.WriteLine(message);
        public void WriteLine(string format, params object[] args) => Console.WriteLine(format, args);
    }
 
    // JSON models for GitHub CLI responses
    private sealed class GitHubPR
    {
        [JsonPropertyName("number")]
        public int Number { get; set; }
 
        [JsonPropertyName("mergedAt")]
        public DateTime MergedAt { get; set; }
 
        [JsonPropertyName("headRefOid")]
        public string HeadRefOid { get; set; } = string.Empty;
    }
 
    private sealed class GitHubWorkflowRun
    {
        [JsonPropertyName("databaseId")]
        public long DatabaseId { get; set; }
 
        [JsonPropertyName("conclusion")]
        public string? Conclusion { get; set; }
    }
 
    private sealed class ArtifactsResponse
    {
        [JsonPropertyName("artifacts")]
        public List<Artifact>? Artifacts { get; set; }
    }
 
    private sealed class Artifact
    {
        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;
    }
}