Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -235,19 +235,14 @@
// caller pass the SHA in, but that leaks Octokit detail into the service layer.
var pr = await client.PullRequest.Get(repository.NamespacePath, repository.Name, number).ConfigureAwait(false);
var headSha = pr.Head?.Sha;
if (string.IsNullOrEmpty(headSha)) return (IReadOnlyList<RemotePullRequestCheck>)Array.Empty<RemotePullRequestCheck>();

try
{
var response = await client.Check.Run.GetAllForReference(repository.NamespacePath, repository.Name, headSha).ConfigureAwait(false);
return (IReadOnlyList<RemotePullRequestCheck>)response.CheckRuns.Select(ToRemoteCheck).ToList();
}
catch (AuthorizationException)
{
// Token lacks `repo` or Checks: Read — graceful empty rather than failing
// the whole PR detail view.
return (IReadOnlyList<RemotePullRequestCheck>)Array.Empty<RemotePullRequestCheck>();
}
// Workflows gate merges on this list, so neither a missing head nor a refused read may become "no checks" —
// an empty list reads as green CI.
if (string.IsNullOrEmpty(headSha))
throw new InvalidOperationException($"GitHub returned pull request #{number} without a head commit, so its checks cannot be read");

var response = await client.Check.Run.GetAllForReference(repository.NamespacePath, repository.Name, headSha).ConfigureAwait(false);
return (IReadOnlyList<RemotePullRequestCheck>)response.CheckRuns.Select(ToRemoteCheck).ToList();
}, cancellationToken).ConfigureAwait(false);
}

Expand Down Expand Up @@ -1273,7 +1268,7 @@
return await _resilience.ExecuteAsync(context.Instance, nameof(RenderMarkdownAsync), async _ =>
{
// `context` = owner/repo so #issues, @mentions, and relative links resolve like on github.com.
var html = await client.Miscellaneous.RenderArbitraryMarkdown(new NewArbitraryMarkdown(markdown, "gfm", repository.FullPath)).ConfigureAwait(false);

Check warning on line 1271 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

'IMiscellaneousClient.RenderArbitraryMarkdown(NewArbitraryMarkdown)' is obsolete: 'This client is being deprecated and will be removed in the future. Use MarkdownClient.RenderArbitraryMarkdown instead.'

Check warning on line 1271 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

'IMiscellaneousClient.RenderArbitraryMarkdown(NewArbitraryMarkdown)' is obsolete: 'This client is being deprecated and will be removed in the future. Use MarkdownClient.RenderArbitraryMarkdown instead.'

Check warning on line 1271 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

'IMiscellaneousClient.RenderArbitraryMarkdown(NewArbitraryMarkdown)' is obsolete: 'This client is being deprecated and will be removed in the future. Use MarkdownClient.RenderArbitraryMarkdown instead.'

Check warning on line 1271 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

'IMiscellaneousClient.RenderArbitraryMarkdown(NewArbitraryMarkdown)' is obsolete: 'This client is being deprecated and will be removed in the future. Use MarkdownClient.RenderArbitraryMarkdown instead.'

Check warning on line 1271 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

'IMiscellaneousClient.RenderArbitraryMarkdown(NewArbitraryMarkdown)' is obsolete: 'This client is being deprecated and will be removed in the future. Use MarkdownClient.RenderArbitraryMarkdown instead.'
return new RemoteRenderedMarkdown { Html = html ?? string.Empty };
}, cancellationToken).ConfigureAwait(false);
}
Expand All @@ -1295,7 +1290,7 @@
{
if (!string.IsNullOrWhiteSpace(instance.ApiUrl)) return new Uri(instance.ApiUrl);
if (string.Equals(instance.BaseUrl?.TrimEnd('/'), "https://github.com", StringComparison.OrdinalIgnoreCase)) return new Uri("https://api.github.com");
return new Uri(instance.BaseUrl.TrimEnd('/') + "/api/v3/");

Check warning on line 1293 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Dereference of a possibly null reference.

Check warning on line 1293 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Dereference of a possibly null reference.

Check warning on line 1293 in backend/src/CodeSpace.Core/Services/Providers/GitHub/GitHubRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Dereference of a possibly null reference.
}

private static RemoteRepository ToRemoteRepository(Octokit.Repository repo) => new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -814,38 +814,67 @@ private static async Task<int> FetchStateCountAsync(string host, int projectId,
public async Task<IReadOnlyList<RemotePullRequestCheck>> ListChecksAsync(ProviderContext context, RemoteRepository repository, int number, CancellationToken cancellationToken)
{
var client = await BuildClientAsync(context, cancellationToken).ConfigureAwait(false);
var projectId = int.Parse(repository.ExternalId);

return await _resilience.ExecuteAsync(context.Instance, nameof(ListChecksAsync), _ =>
{
try
{
var projectId = int.Parse(repository.ExternalId);
var mrClient = client.GetMergeRequest(projectId);
// No catch: workflows gate merges on this list, so a read that fails — rate limit, outage, refused token, dropped
// connection, a payload NGitLab cannot parse — leaves as a failure. Turned into "no checks" it reads as green CI.
return await _resilience.ExecuteAsync(context.Instance, nameof(ListChecksAsync), _ => Task.FromResult(ReadLatestPipelineChecks(client, projectId, number)), cancellationToken).ConfigureAwait(false);
}

// MR pipelines are returned newest-first. We only render checks from the
// LATEST pipeline — older pipelines belong in a "history" view the SPA
// doesn't have yet, and showing all of them at once would be noise.
var pipelines = mrClient.GetPipelines(number).ToList();
if (pipelines.Count == 0) return Task.FromResult<IReadOnlyList<RemotePullRequestCheck>>(Array.Empty<RemotePullRequestCheck>());
/// <summary>
/// The jobs of the merge request's latest pipeline (GitLab lists them newest-first; older pipelines are history), floored
/// by that pipeline's own status. Empty only when GitLab confirms the merge request has no pipeline to show.
/// </summary>
private static IReadOnlyList<RemotePullRequestCheck> ReadLatestPipelineChecks(IGitLabClient client, int projectId, int number)
{
var latest = client.GetMergeRequest(projectId).GetPipelines(number).FirstOrDefault();
if (latest == null) return ConfirmNoPipeline(client, projectId, number);

var latest = pipelines[0];
var jobs = client.GetPipelines(projectId).GetJobs(latest.Id).Select(ToRemoteCheck).ToList();

// IPipelineClient.GetJobs(pipelineId) is the canonical "list jobs in this
// pipeline" endpoint — one round-trip, returns the typed Job[] directly.
var jobs = client.GetPipelines(projectId).GetJobs(latest.Id);
return FloorByPipelineStatus(jobs, ToPipelineCheck(latest));
}

var checks = jobs.Select(ToRemoteCheck).ToList();
return Task.FromResult<IReadOnlyList<RemotePullRequestCheck>>(checks);
}
catch
{
// Token without read_api / pipelines scope, or pipelines simply disabled
// on the project — render no checks rather than failing the PR detail view.
return Task.FromResult<IReadOnlyList<RemotePullRequestCheck>>(Array.Empty<RemotePullRequestCheck>());
}
}, cancellationToken).ConfigureAwait(false);
/// <summary>
/// GitLab answers a merge request's pipeline list with [] both when no pipeline ran and when this credential may read none
/// of them: the list is filtered by read_pipeline, not refused. The read here can use a different credential from the
/// merge (the connection's versus the actor's), so a blind read must not open the gate. [] stands as "no checks" only when
/// CI is off for the project, or when the credential may read the project's pipelines and the merge request names no
/// head pipeline the list left out (one in a fork the credential cannot see).
/// </summary>
private static IReadOnlyList<RemotePullRequestCheck> ConfirmNoPipeline(IGitLabClient client, int projectId, int number)
{
if (IsCiDisabled(client, projectId)) return Array.Empty<RemotePullRequestCheck>();

EnsurePipelinesReadable(client, projectId);
EnsureNoUnlistedHeadPipeline(client, projectId, number);

return Array.Empty<RemotePullRequestCheck>();
}

/// <summary>CI/CD turned off for the project: no pipeline can run, and the project's own pipeline list refuses every credential.</summary>
private static bool IsCiDisabled(IGitLabClient client, int projectId) => client.Projects.GetById(projectId, new SingleProjectQuery()).BuildsAccessLevel == "disabled";

/// <summary>
/// The project's own pipeline list refuses with 403 a credential that may not read pipelines (or jobs), where the merge
/// request's list answers it []. Reading one page is the check; a refusal leaves as one.
/// </summary>
private static void EnsurePipelinesReadable(IGitLabClient client, int projectId) => _ = client.GetPipelines(projectId).Search(new PipelineQuery { PerPage = 1 }).FirstOrDefault();

private static void EnsureNoUnlistedHeadPipeline(IGitLabClient client, int projectId, int number)
{
if (client.GetMergeRequest(projectId)[number].HeadPipeline is { } head)
throw new InvalidOperationException($"GitLab lists no pipeline for merge request !{number}, yet names its head pipeline #{head.Id} ({head.Status.ToString().ToLowerInvariant()}) — a pipeline this credential may not read, so the merge request's checks cannot be read");
}

/// <summary>
/// The pipeline's verdict is a floor under its jobs: when no job carries it — a downstream pipeline failed, a job is
/// missing from the list, the job holding a blocked pipeline reads as an optional manual one — the pipeline joins the list
/// as a check of its own, so a pipeline GitLab does not call success never reads green.
/// </summary>
private static IReadOnlyList<RemotePullRequestCheck> FloorByPipelineStatus(List<RemotePullRequestCheck> jobs, RemotePullRequestCheck pipeline) =>
jobs.Any(job => job.Status == pipeline.Status) ? jobs : jobs.Append(pipeline).ToList();

public async Task<RemotePullRequestComment> PostCommentAsync(ProviderContext context, RemoteRepository repository, int number, string body, CancellationToken cancellationToken)
{
var client = await BuildClientAsync(context, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -1016,6 +1045,30 @@ private static RemotePullRequestCheck ToRemoteCheck(NGitLab.Models.Job job)
};
}

/// <summary>The pipeline itself as a check — what <see cref="FloorByPipelineStatus"/> adds when no job carries the pipeline's verdict.</summary>
private static RemotePullRequestCheck ToPipelineCheck(PipelineBasic pipeline) => new()
{
Name = "pipeline",
Status = MapPipelineStatus(pipeline.Status),
Conclusion = pipeline.Status.ToString().ToLowerInvariant(),
DetailsUrl = pipeline.WebUrl
};

/// <summary>
/// A pipeline passes only when GitLab calls it success. NGitLab types its status as a job status, but the two read
/// differently: a manual or delayed job is optional (Skipped), while a pipeline whose status is manual is blocked on a
/// manual job and one that is scheduled waits on a delayed job — neither is done, so both are Pending. A skipped pipeline
/// ran nothing, and GitLab's own merge check does not count it as success unless the project opts in, so it is
/// Cancelled. Anything else that is not success or failed (created, pending, running, an unknown status) is Pending.
/// </summary>
private static PullRequestCheckStatus MapPipelineStatus(JobStatus status) => status switch
{
JobStatus.Success => PullRequestCheckStatus.Success,
JobStatus.Failed => PullRequestCheckStatus.Failure,
JobStatus.Canceled or JobStatus.Canceling or JobStatus.Skipped => PullRequestCheckStatus.Cancelled,
_ => PullRequestCheckStatus.Pending
};

// GitLab JobStatus: Unknown, Running, Pending, Failed, Success, Created, Canceled, Skipped,
// Manual, NoBuild, Preparing, WaitingForResource, Scheduled, Canceling. Note `Canceled` —
// single L — not `Cancelled`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,15 @@ private async Task AcquireTokenAsync(ProviderInstance instance, string operation
/// 4xx (auth, not-found) do not — retrying just wastes quota. SDK exception types
/// (Octokit.ApiException, NGitLab.GitLabException) all expose a StatusCode property
/// so duck-typed reflection covers every provider without per-SDK exception mapping.
/// A network blip looks different per transport: an HttpClient SDK (Octokit) throws
/// HttpRequestException, while NGitLab speaks HttpWebRequest — a connection it never got
/// an answer on is a WebException without a response, and an answer cut short mid-body is
/// an HttpIOException.
/// </summary>
public static bool IsTransient(Exception exception)
{
if (exception is HttpRequestException) return true;
if (exception is HttpRequestException or HttpIOException) return true;
if (exception is WebException { Response: null }) return true;
if (exception is TaskCanceledException) return true;

var status = ExtractStatusCode(exception);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace CodeSpace.Core.Services.Providers.Resilience;
/// <summary>
/// Wraps every external SDK call (Octokit, NGitLab, future Bitbucket SDK) with two layers:
/// per-ProviderInstance token-bucket rate limiting and exponential-backoff retry on transient
/// failures (HttpRequestException, TaskCanceledException, 5xx HTTP status).
/// failures (HttpRequestException, NGitLab's WebException / HttpIOException, TaskCanceledException, 5xx HTTP status).
/// Provider classes call this for every method that hits the wire. Streaming methods
/// (IAsyncEnumerable) are intentionally NOT wrapped here — caller handles per-page retry.
/// A write that must not land twice (a create, a merge) goes through
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
/// <c>state == "success"</c>) into an If/else so a workflow only merges / proceeds once CI is green.
/// Read-only (not side-effecting). A PR with NO checks reports <c>state = "success"</c> /
/// <c>allPassed = true</c> (vacuously — nothing is pending or failing), mirroring how providers treat a
/// PR with no required checks as mergeable.
/// PR with no required checks as mergeable. "No checks" is only ever the provider's own answer: a checks
/// list the provider could not read (rate limit, outage, refused token, dropped connection) throws out of
/// the read and fails this node, so the gate never branches on a vacuous <c>allPassed</c>.
/// </summary>
public sealed class GitFetchPrChecksNode : INodeRuntime
{
Expand Down
Loading
Loading