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 @@ -377,10 +377,11 @@

// GitHub doesn't delete the head branch on merge — do it as a follow-up when asked, whether this attempt
// merged or an earlier one did.
if (input.DeleteSourceBranch && result.Merged)
await DeleteSourceBranchAsync(context, client, repository, number, cancellationToken).ConfigureAwait(false);
if (!input.DeleteSourceBranch || !result.Merged) return result;

return result;
var (deletion, detail) = await DeleteSourceBranchAsync(context, client, repository, number, cancellationToken).ConfigureAwait(false);

return result with { SourceBranchDeletion = deletion, SourceBranchDetail = detail };
}

private static RemotePullRequestMergeResult ToMergeResult(PullRequestMerge merge) => new() { Merged = merge.Merged, Sha = merge.Sha, Message = merge.Message };
Expand All @@ -393,18 +394,73 @@
return pr.Merged ? new RemotePullRequestMergeResult { Merged = true, Sha = pr.MergeCommitSha } : null;
}

/// <summary>Its own retried step, so a blip here re-runs the cleanup — never the merge. Needs the PR's head ref, so fetch it; a delete failure (already gone / protected) is swallowed so it never fails an otherwise-successful merge.</summary>
private async Task DeleteSourceBranchAsync(ProviderContext context, GitHubClient client, RemoteRepository repository, int number, CancellationToken cancellationToken)
/// <summary>
/// Its own retried step, so a blip here re-runs the cleanup — never the merge. The merge already stands, so a cleanup
/// that cannot be done — refused, failed, or cancelled — is reported in the result, never thrown: a throw would read
/// as a failed merge. A cancel stops the cleanup at its next wait; a delete in flight then is not confirmed either way.
/// </summary>
private async Task<(SourceBranchDeletion Deletion, string Detail)> DeleteSourceBranchAsync(ProviderContext context, GitHubClient client, RemoteRepository repository, int number, CancellationToken cancellationToken)
{
await _resilience.ExecuteAsync(context.Instance, nameof(MergePullRequestAsync) + "/delete-source-branch", async _ =>
try
{
var pr = await client.PullRequest.Get(repository.NamespacePath, repository.Name, number).ConfigureAwait(false);
return await _resilience.ExecuteAsync(context.Instance, nameof(MergePullRequestAsync) + "/delete-source-branch", _ => DeleteOwnHeadBranchAsync(client, repository, number), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return (SourceBranchDeletion.Failed, "The merge stands, but deleting its source branch was cancelled before GitHub confirmed it.");
}
catch (Exception ex)
{
return (SourceBranchDeletion.Failed, $"The merge stands, but its source branch was not deleted: {ex.Message}");
}
}

if (string.IsNullOrEmpty(pr.Head?.Ref)) return;
/// <summary>
/// The credential is the base repository's, and <c>heads/{head.ref}</c> names a branch in the base repository. That is
/// the pull request's branch only when the head lives here: a fork's head names a branch in the fork, and the base's
/// branch of the same name belongs to someone else, so a fork's head is kept.
/// </summary>
private static async Task<(SourceBranchDeletion Deletion, string Detail)> DeleteOwnHeadBranchAsync(GitHubClient client, RemoteRepository repository, int number)
{
var pr = await client.PullRequest.Get(repository.NamespacePath, repository.Name, number).ConfigureAwait(false);

try { await client.Git.Reference.Delete(repository.NamespacePath, repository.Name, $"heads/{pr.Head.Ref}").ConfigureAwait(false); }
catch (ApiException) { /* branch already deleted / protected — the merge still succeeded */ }
}, cancellationToken).ConfigureAwait(false);
if (!IsHeadInBaseRepository(pr)) return (SourceBranchDeletion.SkippedFork, ForkHeadKeptDetail(pr, repository));

await DeleteBranchAsync(client, repository, pr.Head.Ref).ConfigureAwait(false);

return (SourceBranchDeletion.Deleted, $"Deleted '{pr.Head.Ref}' from {repository.FullPath}.");
}

/// <summary>One repository = one GitHub repository id on both ends. GitHub reports a fork deleted since the pull request was opened as no head repository, which is not this one either.</summary>
private static bool IsHeadInBaseRepository(PullRequest pr) => pr.Head?.Repository is { } head && pr.Base?.Repository is { } baseRepository && head.Id == baseRepository.Id;

private static string ForkHeadKeptDetail(PullRequest pr, RemoteRepository repository) =>
$"Kept '{pr.Head?.Ref}': the pull request's head is in {pr.Head?.Repository?.FullName ?? "a repository GitHub no longer reports"}, not {repository.FullPath}, and a source branch is deleted only from its own repository.";

/// <summary>A refused delete of a branch that is already gone (an earlier attempt's delete landed and its answer was lost, or the repository deletes head branches itself) leaves what was asked for; a refusal while the branch is still there stands.</summary>
private static async Task DeleteBranchAsync(GitHubClient client, RemoteRepository repository, string branch)
{
try
{
await client.Git.Reference.Delete(repository.NamespacePath, repository.Name, $"heads/{branch}").ConfigureAwait(false);
}
catch (ApiException)
{
if (await BranchExistsAsync(client, repository, branch).ConfigureAwait(false)) throw;
}
}

private static async Task<bool> BranchExistsAsync(GitHubClient client, RemoteRepository repository, string branch)
{
try
{
await client.Repository.Branch.Get(repository.NamespacePath, repository.Name, branch).ConfigureAwait(false);
return true;
}
catch (NotFoundException)
{
return false;
}
}

public async Task<IReadOnlyList<RemoteIssue>> ListIssuesAsync(ProviderContext context, RemoteRepository repository, IssueState? stateFilter, int page, int perPage, CancellationToken cancellationToken)
Expand Down Expand Up @@ -1268,7 +1324,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 1327 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 1327 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 1327 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 1327 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 @@ -1290,7 +1346,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 1349 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.
}

private static RemoteRepository ToRemoteRepository(Octokit.Repository repo) => new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,17 @@ public async Task<RemotePullRequestMergeResult> MergePullRequestAsync(ProviderCo
cancellationToken).ConfigureAwait(false);

var merged = string.Equals(accepted.State, "merged", StringComparison.OrdinalIgnoreCase) || accepted.MergeCommitSha != null;
return new RemotePullRequestMergeResult { Merged = merged, Sha = accepted.MergeCommitSha };
var result = new RemotePullRequestMergeResult { Merged = merged, Sha = accepted.MergeCommitSha };

if (!input.DeleteSourceBranch || !merged) return result;

return result with { SourceBranchDeletion = SourceBranchDeletion.Requested, SourceBranchDetail = SourceBranchRequestedDetail(accepted) };
}

/// <summary>GitLab deletes the source branch itself, from the merge request's own source project (a fork's branch in the fork, never a same-named branch of the target), and only when the merging identity may push there.</summary>
private static string SourceBranchRequestedDetail(MergeRequest accepted) =>
$"Asked GitLab to delete '{accepted.SourceBranch}' from the merge request's own source project once the merge completes; GitLab does so when the merging identity may push there.";

/// <summary>The merge an earlier attempt landed, read back from the merge request.</summary>
private static async Task<MergeRequest?> FindMergedAsync(IMergeRequestClient mergeRequests, int iid, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
/// completion half of the Git write surface (open → review → merge). Inputs: <c>repositoryId</c>,
/// <c>number</c>, optional <c>method</c> (merge / squash / rebase) / <c>commitTitle</c> /
/// <c>commitMessage</c> / <c>deleteSourceBranch</c> / <c>actAsUserId</c>. Outputs <c>merged</c>, <c>sha</c>,
/// <c>message</c>.
/// <c>message</c>, and what became of the source branch (<c>sourceBranchDeletion</c>, <c>sourceBranchDetail</c>).
///
/// Wire <c>number</c> from upstream (e.g. an auto-merge-after-approval workflow). The provider translates
/// the neutral input to its own API (GitHub merge; GitLab accept).
Expand Down Expand Up @@ -71,7 +71,7 @@ public GitMergePullRequestNode(IPullRequestService prService)
"method": { "type": "string", "enum": ["merge","squash","rebase"], "x-control": "segmented", "x-enumLabels": { "merge": "Merge commit", "squash": "Squash", "rebase": "Rebase" }, "description": "How to integrate the commits. Default: merge commit.", "x-spotlight": 2 },
"commitTitle": { "type": "string", "description": "Optional merge-commit title (squash/merge). Provider default when empty." },
"commitMessage": { "type": "string", "x-long": true, "description": "Optional merge-commit message body." },
"deleteSourceBranch": { "type": "boolean", "description": "Delete the source branch after a successful merge.", "x-spotlight": 3 },
"deleteSourceBranch": { "type": "boolean", "description": "Delete the source branch after a successful merge, only from the pull request's own repository: a fork's branch is never matched to a same-named branch of the base. The sourceBranchDeletion output says what happened.", "x-spotlight": 3 },
"actAsUserId": { "type": "string", "format": "uuid", "x-selector": "actorUser", "description": "Merge AS this CodeSpace user's own linked GitHub/GitLab identity. Omit to use the repository's connection credential." }
},
"required": ["repositoryId","number"]
Expand All @@ -83,7 +83,9 @@ public GitMergePullRequestNode(IPullRequestService prService)
"properties": {
"merged": { "type": "boolean" },
"sha": { "type": ["string","null"] },
"message": { "type": ["string","null"] }
"message": { "type": ["string","null"] },
"sourceBranchDeletion": { "type": "string", "enum": ["NotRequested","Deleted","Requested","SkippedFork","Failed"], "description": "What became of the source branch. Requested: left to the provider (GitLab). SkippedFork: the head lives in a fork, so nothing was deleted. Failed: the merge stands but the branch was not deleted, or its delete was cancelled before it was confirmed." },
"sourceBranchDetail": { "type": ["string","null"], "description": "The same in words: which branch, where, and why it was kept or not deleted." }
}
}
""")
Expand Down Expand Up @@ -115,20 +117,22 @@ public async Task<NodeResult> RunAsync(NodeRunContext context, CancellationToken
action: ct => _prService.MergePullRequestAsync(repoId, teamId, number, input, actAsUserId, ct),
completionExtractor: r => new ExternalCallCompletion
{
ResponsePayload = JsonSerializer.SerializeToElement(new { merged = r.Merged, sha = r.Sha })
ResponsePayload = JsonSerializer.SerializeToElement(new { merged = r.Merged, sha = r.Sha, source_branch_deletion = r.SourceBranchDeletion.ToString() })
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number)); }
catch (ProviderApiException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number)); }

context.Logger.LogInformation("Merged PR #{Num} on repo {RepoId} (merged={Merged}, method {Method})", number, repoId, result.Merged, method);
context.Logger.LogInformation("Merged PR #{Num} on repo {RepoId} (merged={Merged}, method {Method}, source branch {SourceBranchDeletion})", number, repoId, result.Merged, method, result.SourceBranchDeletion);

var outputs = new Dictionary<string, JsonElement>
{
["merged"] = JsonSerializer.SerializeToElement(result.Merged),
["sha"] = JsonSerializer.SerializeToElement(result.Sha),
["message"] = JsonSerializer.SerializeToElement(result.Message)
["message"] = JsonSerializer.SerializeToElement(result.Message),
["sourceBranchDeletion"] = JsonSerializer.SerializeToElement(result.SourceBranchDeletion.ToString()),
["sourceBranchDetail"] = JsonSerializer.SerializeToElement(result.SourceBranchDetail)
};

return NodeResult.Ok(outputs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,39 @@ public sealed record MergePullRequestInput
/// <summary>Optional merge-commit message body. Provider default when null.</summary>
public string? CommitMessage { get; init; }

/// <summary>Delete the source branch after a successful merge. Default false.</summary>
/// <summary>Delete the source branch after a successful merge — only from the pull request's own repository, never a same-named branch of the base for a fork's pull request. Default false.</summary>
public bool DeleteSourceBranch { get; init; }
}

/// <summary>What became of a merged pull request's source branch. Provider-neutral.</summary>
public enum SourceBranchDeletion
{
/// <summary>No delete was attempted: the merge did not ask for one, or nothing merged.</summary>
NotRequested,

/// <summary>The branch is gone from the pull request's own repository — this merge deleted it, or it was already gone when asked.</summary>
Deleted,

/// <summary>Handed to the provider, which removes the branch from the request's own source project after the merge when the merging identity may (GitLab).</summary>
Requested,

/// <summary>Kept: the head branch lives in another repository (a fork). The base repository's ref of the same name is a different branch, so nothing is deleted.</summary>
SkippedFork,

/// <summary>The delete was refused, could not be made, or was cancelled before it was confirmed. The merge still stands; the detail says why.</summary>
Failed
}

/// <summary>Outcome of a merge: whether it merged, and (when available) the resulting commit sha + a provider message.</summary>
public sealed record RemotePullRequestMergeResult
{
public required bool Merged { get; init; }
public string? Sha { get; init; }
public string? Message { get; init; }

/// <summary>What became of the source branch. <see cref="SourceBranchDeletion.NotRequested"/> unless the merge asked for it to go.</summary>
public SourceBranchDeletion SourceBranchDeletion { get; init; }

/// <summary>The same in words: which branch, where, and why it was kept or not deleted. Null when no delete was asked for.</summary>
public string? SourceBranchDetail { get; init; }
}
Loading
Loading