using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace Facepunch; internal static class GitHub { private static readonly HttpClient httpClient = new(); private static readonly Dictionary checkRunIds = []; public record struct CheckRunParams( string Name, string Status, string Conclusion = null, string Title = null, string Summary = null, string Details = null ); /// /// Post a check run status to GitHub /// public static bool PostCheckRun( CheckRunParams parameters ) { if ( !Utility.IsCi() ) return true; try { return PostCheckRunAsync( parameters ).GetAwaiter().GetResult(); } catch ( Exception ex ) { Console.WriteLine( $"Error posting check run: {ex.Message}" ); return false; } } /// /// Gets the check run ID for a step name if available /// public static bool TryGetCheckRunId( string stepName, out long checkRunId ) { return checkRunIds.TryGetValue( stepName, out checkRunId ); } private static async Task PostCheckRunAsync( CheckRunParams parameters ) { // Validate required environment variables if ( !TryGetTokenAndRepositoryFromEnv( out var token, out var repository ) ) return false; // Get the SHA string sha = GetCommitSha(); if ( string.IsNullOrEmpty( sha ) ) return false; // Get timestamps in ISO 8601 format string currentTime = DateTime.UtcNow.ToString( "yyyy-MM-ddTHH:mm:ssZ" ); // Set headers for all requests SetupHttpClient( token ); // If we have an ID for this check run, update it instead of creating a new one if ( checkRunIds.TryGetValue( parameters.Name, out long checkRunId ) ) { if ( await UpdateCheckRun( repository, checkRunId, parameters, currentTime ) ) { return true; } // If update failed, we'll fall through to create a new check run } // Create a new check run return await CreateCheckRun( repository, sha, parameters, currentTime ); } private static async Task UpdateCheckRun( string repository, long checkRunId, CheckRunParams parameters, string currentTime ) { // Build update payload - slightly different from create payload (no head_sha) object updatePayload; var outputObject = new { title = parameters.Title ?? parameters.Name, summary = parameters.Summary ?? string.Empty, text = parameters.Details ?? string.Empty }; if ( parameters.Status == "completed" ) { updatePayload = new { status = parameters.Status, conclusion = parameters.Conclusion ?? "neutral", completed_at = currentTime, output = outputObject }; } else { updatePayload = new { status = parameters.Status, output = outputObject }; } var jsonContent = JsonSerializer.Serialize( updatePayload ); var content = new StringContent( jsonContent, Encoding.UTF8, "application/json" ); var updateUrl = $"https://api.github.com/repos/{repository}/check-runs/{checkRunId}"; // Use PATCH to update the existing check run var response = await httpClient.PatchAsync( updateUrl, content ); if ( !response.IsSuccessStatusCode ) { var responseContent = await response.Content.ReadAsStringAsync(); Console.WriteLine( $"GitHub API error updating check run: {response.StatusCode} - {responseContent}" ); // If the check run no longer exists (404), remove it from our dictionary // and create a new one instead if ( response.StatusCode == System.Net.HttpStatusCode.NotFound ) { checkRunIds.Remove( parameters.Name ); } return false; } return true; } private static async Task CreateCheckRun( string repository, string sha, CheckRunParams parameters, string currentTime ) { object updatePayload; var outputObject = new { title = parameters.Title ?? parameters.Name, summary = parameters.Summary ?? string.Empty, text = parameters.Details ?? string.Empty }; if ( parameters.Status == "completed" ) { updatePayload = new { name = parameters.Name, head_sha = sha, status = parameters.Status, conclusion = parameters.Conclusion ?? "neutral", started_at = currentTime, completed_at = currentTime, output = outputObject }; } else { updatePayload = new { name = parameters.Name, head_sha = sha, status = parameters.Status, started_at = currentTime, output = outputObject }; } var jsonContent = JsonSerializer.Serialize( updatePayload ); var content = new StringContent( jsonContent, Encoding.UTF8, "application/json" ); var url = $"https://api.github.com/repos/{repository}/check-runs"; // Send request to create a new check run var response = await httpClient.PostAsync( url, content ); if ( !response.IsSuccessStatusCode ) { var errorResponseContent = await response.Content.ReadAsStringAsync(); Console.WriteLine( $"GitHub API error creating check run: {response.StatusCode} - {errorResponseContent}" ); return false; } var responseContent = await response.Content.ReadAsStringAsync(); // Simple parsing of the response to get the ID using var jsonDoc = JsonDocument.Parse( responseContent ); if ( jsonDoc.RootElement.TryGetProperty( "id", out var idElement ) ) { long id = idElement.GetInt64(); checkRunIds[parameters.Name] = id; } return true; } /// /// Gets a URL to the current workflow's logs /// public static string GetWorkflowLogsUrl() { return GetWorkflowLogsUrlAsync().GetAwaiter().GetResult(); } private static async Task GetWorkflowLogsUrlAsync() { // Get required environment variables if ( !TryGetTokenAndRepositoryFromEnv( out var token, out var repository ) ) return string.Empty; SetupHttpClient( token ); var runId = Environment.GetEnvironmentVariable( "GITHUB_RUN_ID" ); if ( string.IsNullOrEmpty( runId ) ) { Console.WriteLine( "GITHUB_RUN_ID not set" ); return string.Empty; } long jobId = await FindJobId( repository, runId ); // Construct the logs URL if ( jobId != -1 ) { return $"https://github.com/{repository}/actions/runs/{runId}/job/{jobId}"; } return $"https://github.com/{repository}/actions/runs/{runId}"; } private static async Task FindJobId( string repository, string runId ) { long jobId = -1; try { // Query the API to get jobs for this run var jobsUrl = $"https://api.github.com/repos/{repository}/actions/runs/{runId}/jobs"; var response = await httpClient.GetAsync( jobsUrl ); if ( !response.IsSuccessStatusCode ) { var errorContent = await response.Content.ReadAsStringAsync(); Console.WriteLine( $"GitHub API error: {response.StatusCode} - {errorContent}" ); return jobId; } var content = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse( content ); // Get the current job name var jobName = Environment.GetEnvironmentVariable( "GITHUB_JOB" ); // Make sure the jobs property exists if ( !doc.RootElement.TryGetProperty( "jobs", out var jobs ) ) { Console.WriteLine( "Jobs property not found in API response" ); return jobId; } // First pass: try to find exact job name match foreach ( var job in jobs.EnumerateArray() ) { if ( job.TryGetProperty( "name", out var nameElement ) && string.Equals( nameElement.GetString(), jobName, StringComparison.Ordinal ) ) { jobId = job.GetProperty( "id" ).GetInt64(); break; } } // Second pass: try case-insensitive match if exact match failed if ( jobId == -1 ) { foreach ( var job in jobs.EnumerateArray() ) { if ( job.TryGetProperty( "name", out var nameElement ) && string.Equals( nameElement.GetString(), jobName, StringComparison.OrdinalIgnoreCase ) ) { jobId = job.GetProperty( "id" ).GetInt64(); break; } } } // If we still couldn't find by name, use the first job if ( jobId == -1 && jobs.GetArrayLength() > 0 ) { var firstJob = jobs[0]; jobId = firstJob.GetProperty( "id" ).GetInt64(); } } catch ( Exception ex ) { Console.WriteLine( $"Error getting job ID: {ex.Message}" ); } return jobId; } private static string GetCommitSha() { string sha; var eventName = Environment.GetEnvironmentVariable( "GITHUB_EVENT_NAME" ); if ( eventName == "pull_request" ) { // For pull requests, we need to get the head SHA from the event payload sha = Environment.GetEnvironmentVariable( "GITHUB_EVENT_PULL_REQUEST_HEAD_SHA" ); if ( string.IsNullOrEmpty( sha ) ) { // Try to get it from the event file try { var eventPath = Environment.GetEnvironmentVariable( "GITHUB_EVENT_PATH" ); if ( !string.IsNullOrEmpty( eventPath ) ) { var eventJson = System.IO.File.ReadAllText( eventPath ); using ( var doc = JsonDocument.Parse( eventJson ) ) { sha = doc.RootElement .GetProperty( "pull_request" ) .GetProperty( "head" ) .GetProperty( "sha" ) .GetString(); } } } catch { // If we can't get the SHA from the event file, fall back to GITHUB_SHA sha = Environment.GetEnvironmentVariable( "GITHUB_SHA" ); } } } else { // For other events, use the commit SHA sha = Environment.GetEnvironmentVariable( "GITHUB_SHA" ); } if ( string.IsNullOrEmpty( sha ) ) { Console.WriteLine( "Commit SHA not available" ); } return sha ?? ""; } private static bool TryGetTokenAndRepositoryFromEnv( out string token, out string repository ) { token = Environment.GetEnvironmentVariable( "GITHUB_TOKEN" ) ?? string.Empty; if ( string.IsNullOrEmpty( token ) ) { Console.WriteLine( "GITHUB_TOKEN not set" ); repository = string.Empty; return false; } repository = Environment.GetEnvironmentVariable( "GITHUB_REPOSITORY" ) ?? string.Empty; if ( string.IsNullOrEmpty( repository ) ) { Console.WriteLine( "GITHUB_REPOSITORY not set" ); return false; } return true; } private static void SetupHttpClient( string token ) { httpClient.DefaultRequestHeaders.Clear(); httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue( "application/vnd.github+json" ) ); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", token ); httpClient.DefaultRequestHeaders.Add( "User-Agent", "SboxBuild" ); httpClient.DefaultRequestHeaders.Add( "X-GitHub-Api-Version", "2022-11-28" ); } }