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
15 changes: 15 additions & 0 deletions src/Polyphony/Commands/CommandExecution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Polyphony.Commands;

/// <summary>
/// Output of an internal verb method (the typed <c>*Async</c> seam introduced
/// by AB#3313): pairs the typed result record the verb computed with the
/// process exit code the public shell will return.
/// </summary>
/// <remarks>
/// The public verb method is a thin shell — parse envelope → call typed
/// <c>*Async</c> method → serialize <see cref="Result"/> to stdout → return
/// <see cref="ExitCode"/>. The typed method itself is pure of JSON shape,
/// CLI binding, and console I/O; callers like the future <c>PrLifecycle</c>
/// skeleton can compose typed without round-tripping through JSON.
/// </remarks>
internal sealed record CommandExecution<TResult>(TResult Result, int ExitCode);
289 changes: 142 additions & 147 deletions src/Polyphony/Commands/PrCommands.MergePlanAdo.cs

Large diffs are not rendered by default.

255 changes: 125 additions & 130 deletions src/Polyphony/Commands/PrCommands.MergePlanPr.cs

Large diffs are not rendered by default.

292 changes: 135 additions & 157 deletions src/Polyphony/Commands/PrCommands.OpenPlanAdo.cs

Large diffs are not rendered by default.

245 changes: 105 additions & 140 deletions src/Polyphony/Commands/PrCommands.OpenPlanPr.cs

Large diffs are not rendered by default.

47 changes: 34 additions & 13 deletions src/Polyphony/Commands/PrCommands.PollStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,40 @@ public async Task<int> PollStatus(
("--pr-url", string.IsNullOrEmpty(prUrl))) is { } halt)
return halt;

var execution = await PollStatusAsync(prUrl, includeMetadata, ct).ConfigureAwait(false);
EmitPoll(execution.Result);
return execution.ExitCode;
}

/// <summary>
/// Typed seam introduced by AB#3313. Computes the
/// <see cref="PrPollStatusResult"/> envelope without any stdout side
/// effects. The public <see cref="PollStatus"/> shell parses the CLI
/// envelope, calls this method, serializes the result, and translates
/// to a process exit code. Composable from in-process callers such as
/// the future <c>PrLifecycle</c> skeleton without round-tripping through
/// JSON.
/// </summary>
internal async Task<CommandExecution<PrPollStatusResult>> PollStatusAsync(
string prUrl,
bool includeMetadata,
CancellationToken ct)
{
if (!TryParsePrUrl(prUrl, out var slug, out var prNumber))
{
EmitPollError(prUrl, $"could not parse pr url '{prUrl}' (expected https://github.com/owner/repo/pull/N)");
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollErrorResult(prUrl, $"could not parse pr url '{prUrl}' (expected https://github.com/owner/repo/pull/N)"),
ExitCodes.Success);
}

try
{
var data = await gh.GetPullRequestPollDataAsync(slug, prNumber, ct).ConfigureAwait(false);
if (data is null)
{
EmitPollError(prUrl, $"PR #{prNumber} not found in {slug}", slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollErrorResult(prUrl, $"PR #{prNumber} not found in {slug}", slug, prNumber),
ExitCodes.Success);
}

// Build review-thread snapshot. Threads are now the source
Expand Down Expand Up @@ -128,19 +149,20 @@ public async Task<int> PollStatus(
Warnings = warnings,
Metadata = metadata,
};
EmitPoll(result);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(result, ExitCodes.Success);
}
catch (OperationCanceledException) { throw; }
catch (ExternalToolTimeoutException ex)
{
EmitPollError(prUrl, ex.FormatErrorMessage("gh pr view"), slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollErrorResult(prUrl, ex.FormatErrorMessage("gh pr view"), slug, prNumber),
ExitCodes.Success);
}
catch (Exception ex)
{
EmitPollError(prUrl, ex.Message, slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollErrorResult(prUrl, ex.Message, slug, prNumber),
ExitCodes.Success);
}
}

Expand Down Expand Up @@ -252,13 +274,13 @@ private static void EmitPoll(PrPollStatusResult result)
=> Console.WriteLine(JsonSerializer.Serialize(
result, PolyphonyJsonContext.Default.PrPollStatusResult));

private static void EmitPollError(
private static PrPollStatusResult BuildPollErrorResult(
string prUrl,
string message,
string repoSlug = "",
int prNumber = 0)
{
var result = new PrPollStatusResult
return new PrPollStatusResult
{
PrUrl = prUrl,
PrNumber = prNumber,
Expand All @@ -277,6 +299,5 @@ private static void EmitPollError(
Policy = new PrPollPolicy { MergeAllowed = false, BlockingReasons = [message] },
Error = message,
};
EmitPoll(result);
}
}
113 changes: 72 additions & 41 deletions src/Polyphony/Commands/PrCommands.PollStatusAdo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,41 +58,69 @@ public async Task<int> PollStatusAdo(
("--pr-number", prNumber == RequiredInput.MissingInt)) is { } halt)
return halt;

var execution = await PollStatusAdoAsync(
organization, project, repositoryId, prNumber, includeMetadata, allowAnyApprovalVote, ct)
.ConfigureAwait(false);
EmitPollStatusAdo(execution.Result);
return execution.ExitCode;
}

/// <summary>
/// Typed seam introduced by AB#3313. Computes the
/// <see cref="PrPollStatusResult"/> envelope without any stdout side
/// effects. The public <see cref="PollStatusAdo"/> shell parses the CLI
/// envelope, calls this method, serializes the result, and translates
/// to a process exit code. Composable from in-process callers such as
/// the future <c>PrLifecycle</c> skeleton without round-tripping through
/// JSON.
/// </summary>
internal async Task<CommandExecution<PrPollStatusResult>> PollStatusAdoAsync(
string organization,
string project,
string repositoryId,
int prNumber,
bool includeMetadata,
bool allowAnyApprovalVote,
CancellationToken ct)
{
var prUrl = BuildAdoPrUrl(organization, project, repositoryId, prNumber);

if (string.IsNullOrWhiteSpace(organization)
|| string.IsNullOrWhiteSpace(project)
|| string.IsNullOrWhiteSpace(repositoryId))
{
EmitPollStatusAdoError(
prUrl,
"organization, project, and repositoryId are required",
"invalid_argument",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(
prUrl,
"organization, project, and repositoryId are required",
"invalid_argument",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber),
ExitCodes.Success);
}
if (prNumber <= 0)
{
EmitPollStatusAdoError(
prUrl,
$"prNumber must be a positive integer (got {prNumber})",
"invalid_argument",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(
prUrl,
$"prNumber must be a positive integer (got {prNumber})",
"invalid_argument",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber),
ExitCodes.Success);
}
if (ado is null)
{
// Shouldn't happen in production (DI registers IAdoClient) but the
// ctor allows null so unit tests can opt out of the ADO leg.
EmitPollStatusAdoError(
prUrl,
"IAdoClient is not configured",
"ado_failed",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(
prUrl,
"IAdoClient is not configured",
"ado_failed",
slug: BuildAdoSlug(organization, project, repositoryId),
prNumber: prNumber),
ExitCodes.Success);
}

var slug = BuildAdoSlug(organization, project, repositoryId);
Expand All @@ -103,13 +131,14 @@ public async Task<int> PollStatusAdo(
organization, project, repositoryId, prNumber, allowAnyApprovalVote, ct).ConfigureAwait(false);
if (data is null)
{
EmitPollStatusAdoError(
prUrl,
$"PR #{prNumber} not found in {slug}",
"pr_not_found",
slug: slug,
prNumber: prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(
prUrl,
$"PR #{prNumber} not found in {slug}",
"pr_not_found",
slug: slug,
prNumber: prNumber),
ExitCodes.Success);
}

// Fetch ADO threads alongside the PR detail. Threads drive the
Expand Down Expand Up @@ -207,34 +236,37 @@ public async Task<int> PollStatusAdo(
Warnings = warnings,
Metadata = metadata,
};
EmitPollStatusAdo(result);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(result, ExitCodes.Success);
}
catch (OperationCanceledException) { throw; }
catch (AdoAuthenticationException ex)
{
// Raised by IPolyphonyAuthProvider when no ADO credential chain succeeds (PAT env or AAD).
EmitPollStatusAdoError(prUrl, ex.Message, "no_pat", slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(prUrl, ex.Message, "no_pat", slug, prNumber),
ExitCodes.Success);
}
catch (TimeoutException ex)
{
EmitPollStatusAdoError(prUrl, ex.Message, "ado_timeout", slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(prUrl, ex.Message, "ado_timeout", slug, prNumber),
ExitCodes.Success);
}
catch (HttpRequestException ex)
{
// 401/403 → no_pat (PAT is missing or rejected); everything else → ado_failed.
var code = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
? "no_pat"
: "ado_failed";
EmitPollStatusAdoError(prUrl, ex.Message, code, slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(prUrl, ex.Message, code, slug, prNumber),
ExitCodes.Success);
}
catch (Exception ex)
{
EmitPollStatusAdoError(prUrl, ex.Message, "ado_failed", slug, prNumber);
return ExitCodes.Success;
return new CommandExecution<PrPollStatusResult>(
BuildPollStatusAdoErrorResult(prUrl, ex.Message, "ado_failed", slug, prNumber),
ExitCodes.Success);
}
}

Expand Down Expand Up @@ -332,14 +364,14 @@ private static void EmitPollStatusAdo(PrPollStatusResult result)
=> Console.WriteLine(JsonSerializer.Serialize(
result, PolyphonyJsonContext.Default.PrPollStatusResult));

private static void EmitPollStatusAdoError(
private static PrPollStatusResult BuildPollStatusAdoErrorResult(
string prUrl,
string message,
string errorCode,
string slug,
int prNumber)
{
var result = new PrPollStatusResult
return new PrPollStatusResult
{
PrUrl = prUrl,
PrNumber = prNumber,
Expand All @@ -359,6 +391,5 @@ private static void EmitPollStatusAdoError(
Error = message,
ErrorCode = errorCode,
};
EmitPollStatusAdo(result);
}
}
Loading