diff --git a/src/Polyphony/Commands/StatusCommand.cs b/src/Polyphony/Commands/StatusCommand.cs new file mode 100644 index 00000000..b3312beb --- /dev/null +++ b/src/Polyphony/Commands/StatusCommand.cs @@ -0,0 +1,384 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using ConsoleAppFramework; +using Polyphony.Annotations; +using Polyphony.Infrastructure.Processes; +using Polyphony.Manifest; +using Polyphony.Tagging; +using Twig.Domain.Interfaces; + +namespace Polyphony.Commands; + +/// +/// polyphony status — periodic dashboard snapshot for a single apex. +/// Composes the ADO cache, the run manifest, and a best-effort gh PR query +/// into a unified JSON envelope plus a human-readable headline. Designed +/// for polling (e.g. a dashboard widget after each conductor event). +/// +/// Routing-style verb: ALWAYS exits 0. Failure modes (work item +/// missing, manifest unparseable, gh hung) are surfaced through per-section +/// error fields and the array, +/// not via process exit code. The dashboard cares about cross-signal +/// coherence — a wedged gh shouldn't blank the whole report. +/// +/// Cross-signal warnings caught today: +/// +/// apex_not_in_scope — work item missing the +/// / tag. +/// apex_not_root — work item in-scope but missing +/// ; status was likely invoked on a +/// descendant by mistake. +/// planned_tag_zero_children — apex carries +/// but has zero ADO children. +/// This is the AB#3064 false-satisfied dogfood bug — the seeder +/// stamped the tag with empty input. Now caught at lint time by +/// PR #225's strict seed-children, but the warning stays as a +/// belt-and-braces dashboard signal. +/// manifest_missing — no .polyphony/run.yaml; either +/// the run hasn't started or the working directory isn't the run root. +/// feature_pr_unmerged_progress — manifest records merged +/// plan PRs but no feature PR exists or it's still open. Honest +/// signal that work has happened but hasn't been promoted. +/// +/// +[VerbGroup("")] +public sealed class StatusCommand( + IWorkItemRepository repository, + IGitClient git, + IGhClient gh) +{ + private static readonly Regex GitHubSlugRegex = + new(@"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?(?:[/?#].*)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// Compose a periodic status snapshot for an apex work item. + /// Apex (focus) work item ID. + /// Owner/repo slug for the gh feature-PR lookup. + /// When empty, derived from the origin remote; when no slug can + /// be derived, the feature_pr section reports error: no_slug. + /// Run manifest path. Defaults to .polyphony/run.yaml. + /// Cancellation token. + [Command("status")] + [VerbResult(typeof(StatusResult))] + public async Task Status( + int apex = RequiredInput.MissingInt, + string repoSlug = "", + string manifestPath = RunManifestStore.DefaultRelativePath, + CancellationToken ct = default) + { + if (RequiredInput.HaltIfMissing("status", + ("--apex", apex == RequiredInput.MissingInt)) is { } halt) + return halt; + + var ado = await ReadAdoSectionAsync(apex, ct).ConfigureAwait(false); + var manifest = ReadManifestSection(manifestPath); + var featurePr = await ReadFeaturePrSectionAsync(apex, repoSlug, ct).ConfigureAwait(false); + var binary = ReadBinarySection(); + + var warnings = ComputeWarnings(ado, manifest, featurePr); + var (headline, nextAction) = ComputeHeadline(apex, ado, manifest, featurePr, warnings); + + var result = new StatusResult + { + ApexId = apex, + Ado = ado, + Manifest = manifest, + FeaturePr = featurePr, + Binary = binary, + Warnings = warnings, + Headline = headline, + NextAction = nextAction, + }; + + Console.WriteLine(JsonSerializer.Serialize( + result, PolyphonyJsonContext.Default.StatusResult)); + return ExitCodes.Success; + } + + private async Task ReadAdoSectionAsync(int apex, CancellationToken ct) + { + var item = await repository.GetByIdAsync(apex, ct).ConfigureAwait(false); + if (item is null) + { + return new StatusAdoSection + { + Found = false, + Tags = [], + InScope = false, + IsRoot = false, + HasPlannedTag = false, + ChildrenCount = 0, + Error = $"Work item {apex} not found in twig cache", + }; + } + + item.Fields.TryGetValue("System.Tags", out var rawTags); + var tags = TagSet.Parse(rawTags); + + int childrenCount; + try + { + var children = await repository.GetChildrenAsync(apex, ct).ConfigureAwait(false); + childrenCount = children.Count; + } + catch + { + childrenCount = 0; + } + + return new StatusAdoSection + { + Found = true, + Type = item.Type.Value, + State = item.State, + Title = item.Title, + Tags = tags.ToArray(), + InScope = PolyphonyTags.IsInScope(tags), + IsRoot = PolyphonyTags.IsRoot(tags), + HasPlannedTag = tags.Contains(PolyphonyTags.Planned), + ChildrenCount = childrenCount, + }; + } + + private static StatusManifestSection ReadManifestSection(string manifestPath) + { + if (!File.Exists(manifestPath)) + { + return new StatusManifestSection + { + Exists = false, + Path = manifestPath, + }; + } + + try + { + var manifest = RunManifestStore.LoadOrThrow(manifestPath); + int? rootGen = manifest.PlanGenerations is { Count: > 0 } pg + && pg.TryGetValue("root", out var v) ? v : null; + return new StatusManifestSection + { + Exists = true, + Path = manifestPath, + FeatureBranch = $"feature/{manifest.RootId}", + PlanGenerationsRoot = rootGen, + MergedPlanPrsCount = manifest.MergedPlanPrs?.Count ?? 0, + MergeGroupsCount = manifest.MergeGroups?.Count ?? 0, + }; + } + catch (Exception ex) + { + return new StatusManifestSection + { + Exists = true, + Path = manifestPath, + Error = ex.Message, + }; + } + } + + private async Task ReadFeaturePrSectionAsync( + int apex, string repoSlug, CancellationToken ct) + { + if (string.IsNullOrEmpty(repoSlug)) + { + try + { + var url = await git.GetRemoteUrlAsync("origin", ct).ConfigureAwait(false); + if (!string.IsNullOrEmpty(url)) + { + var match = GitHubSlugRegex.Match(url); + if (match.Success) repoSlug = match.Groups[1].Value; + } + } + catch + { + // Fall through — no slug → no PR lookup. + } + } + + if (string.IsNullOrEmpty(repoSlug)) + { + return new StatusFeaturePrSection + { + Exists = false, + Error = "no_slug", + }; + } + + var headBranch = $"feature/{apex}"; + try + { + // Look at all states (open/merged/closed) so a merged feature PR is + // visible. gh's --state default is `open`, which would hide a merged + // PR and trip the feature_pr_unmerged_progress warning incorrectly. + var prs = await gh.ListPullRequestsAsync( + repoSlug, + new PrListFilters(Head: headBranch, State: "all", Limit: 5), + ct).ConfigureAwait(false); + + var match = prs.FirstOrDefault(p => string.Equals( + p.HeadRefName, headBranch, StringComparison.Ordinal)); + if (match is null) + { + return new StatusFeaturePrSection { Exists = false }; + } + + var state = match.MergedAt.HasValue ? "MERGED" : "OPEN"; + return new StatusFeaturePrSection + { + Exists = true, + Number = match.Number, + Url = match.Url, + State = state, + MergedAt = match.MergedAt?.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + }; + } + catch (Exception ex) + { + return new StatusFeaturePrSection + { + Exists = false, + Error = ex.Message, + }; + } + } + + private static StatusBinarySection ReadBinarySection() + { + var asm = typeof(StatusCommand).Assembly; + var informational = asm.GetCustomAttribute()?.InformationalVersion + ?? "unknown"; + var version = asm.GetName().Version?.ToString() ?? "unknown"; + string? location = null; + try + { + location = Environment.ProcessPath; + } + catch + { + // Best-effort; never poison the result. + } + + return new StatusBinarySection + { + Version = version, + InformationalVersion = informational, + Location = location, + }; + } + + private static IReadOnlyList ComputeWarnings( + StatusAdoSection ado, + StatusManifestSection manifest, + StatusFeaturePrSection featurePr) + { + var warnings = new List(); + + if (ado.Found) + { + if (!ado.InScope) + { + warnings.Add(new StatusWarning + { + Code = "apex_not_in_scope", + Message = "Work item is missing the polyphony / polyphony:root tag.", + }); + } + else if (!ado.IsRoot) + { + warnings.Add(new StatusWarning + { + Code = "apex_not_root", + Message = "Work item carries the polyphony tag but not polyphony:root. status was probably invoked on a descendant.", + }); + } + + if (ado.HasPlannedTag && ado.ChildrenCount == 0) + { + warnings.Add(new StatusWarning + { + Code = "planned_tag_zero_children", + Message = "polyphony:planned tag is set but the work item has no ADO children. Closed-loop §3.4(a) requires apex_facets in the plan front-matter when the apex is genuinely indivisible; otherwise this is a false-satisfied bug.", + }); + } + } + + if (!manifest.Exists) + { + warnings.Add(new StatusWarning + { + Code = "manifest_missing", + Message = $"Run manifest not found at {manifest.Path ?? RunManifestStore.DefaultRelativePath}. Either the run has not started or the working directory is not the run root.", + }); + } + + if (manifest.Exists + && manifest.MergedPlanPrsCount is > 0 + && (!featurePr.Exists || string.Equals(featurePr.State, "OPEN", StringComparison.Ordinal))) + { + warnings.Add(new StatusWarning + { + Code = "feature_pr_unmerged_progress", + Message = "Manifest records merged plan PRs but the feature PR is not yet merged. Implementation work has happened but is not promoted to main.", + }); + } + + return warnings; + } + + private static (string Headline, string? NextAction) ComputeHeadline( + int apex, + StatusAdoSection ado, + StatusManifestSection manifest, + StatusFeaturePrSection featurePr, + IReadOnlyList warnings) + { + if (!ado.Found) + { + return ($"apex {apex}: not found in twig cache", + "Run `twig sync` to refresh the cache, or verify the work item ID."); + } + + // Most-actionable warning takes the headline. + var plannedZero = warnings.FirstOrDefault(w => w.Code == "planned_tag_zero_children"); + if (plannedZero is not null) + { + return ($"apex {apex}: planned but no children — false-satisfied bug", + $"Inspect the plan: `cat plans/plan-{apex}.md`. Re-run plan-level once F4 lint catches prose-only declarations."); + } + + var notInScope = warnings.FirstOrDefault(w => w.Code == "apex_not_in_scope"); + if (notInScope is not null) + { + return ($"apex {apex}: not in polyphony scope (tag missing)", + $"polyphony root declare --work-item {apex}"); + } + + var unmergedProgress = warnings.FirstOrDefault(w => w.Code == "feature_pr_unmerged_progress"); + if (unmergedProgress is not null) + { + var prSegment = featurePr is { Exists: true, Number: { } n } + ? $"feature PR #{n} is open" + : "no feature PR exists"; + return ($"apex {apex}: progress recorded ({manifest.MergedPlanPrsCount} plan PR(s) merged) but {prSegment}", + "Check `gh pr list --state open` for an open feature PR; ship it once impl PRs are merged."); + } + + if (featurePr is { Exists: true, State: "MERGED" }) + { + return ($"apex {apex}: feature PR #{featurePr.Number} merged", + "Verify ADO state has advanced (item_satisfied transition pending until F5 lands)."); + } + + if (!manifest.Exists) + { + return ($"apex {apex}: manifest not initialised (state={ado.State})", + $"conductor run apex-driver@polyphony --input apex_id={apex}"); + } + + return ($"apex {apex}: in flight (state={ado.State}, children={ado.ChildrenCount}, merged plan PRs={manifest.MergedPlanPrsCount ?? 0})", + "Run `polyphony state next-ready --work-item {apex}` for the next dispatchable requirement."); + } +} diff --git a/src/Polyphony/Models/StatusResult.cs b/src/Polyphony/Models/StatusResult.cs new file mode 100644 index 00000000..d8b12f82 --- /dev/null +++ b/src/Polyphony/Models/StatusResult.cs @@ -0,0 +1,105 @@ +namespace Polyphony; + +/// +/// Aggregated dashboard snapshot for a single apex work item. Composed by +/// polyphony status from the ADO cache, the run manifest, and a +/// best-effort gh PR query. Always exit 0 (routing-style); failure modes +/// are surfaced via the array and per-section +/// error fields rather than via process exit code. +/// +/// Designed for periodic polling (e.g. a dashboard widget). Cross-signal +/// detections live in ; the +/// + pair give a one-line human-readable summary. +/// +public sealed record StatusResult +{ + public required int ApexId { get; init; } + public required StatusAdoSection Ado { get; init; } + public required StatusManifestSection Manifest { get; init; } + public required StatusFeaturePrSection FeaturePr { get; init; } + public required StatusBinarySection Binary { get; init; } + public required IReadOnlyList Warnings { get; init; } + public required string Headline { get; init; } + public string? NextAction { get; init; } +} + +/// +/// ADO-side observable signals: type, state, title, raw tags, plus +/// derived booleans for the polyphony tag namespace and the count of +/// direct children. is populated when the lookup +/// failed (e.g. work item not in cache); the rest of the section is +/// best-effort empty in that case so consumers can still render. +/// +public sealed record StatusAdoSection +{ + public required bool Found { get; init; } + public string? Type { get; init; } + public string? State { get; init; } + public string? Title { get; init; } + public required IReadOnlyList Tags { get; init; } + public required bool InScope { get; init; } + public required bool IsRoot { get; init; } + public required bool HasPlannedTag { get; init; } + public required int ChildrenCount { get; init; } + public string? Error { get; init; } +} + +/// +/// Run-manifest snapshot. false means the file is +/// absent (not yet initialised). When present, the rest of the section +/// reflects the parsed manifest. is populated when +/// the file existed but failed to parse. +/// +public sealed record StatusManifestSection +{ + public required bool Exists { get; init; } + public string? Path { get; init; } + public string? FeatureBranch { get; init; } + public int? PlanGenerationsRoot { get; init; } + public int? MergedPlanPrsCount { get; init; } + public int? MergeGroupsCount { get; init; } + public string? Error { get; init; } +} + +/// +/// Feature PR (head feature/{apex_id} → main) summary. The lookup +/// is gh-best-effort: a missing PR yields false, a +/// transient gh failure yields populated and the rest +/// of the section empty. +/// +public sealed record StatusFeaturePrSection +{ + public required bool Exists { get; init; } + public int? Number { get; init; } + public string? Url { get; init; } + public string? State { get; init; } + public string? MergedAt { get; init; } + public string? Error { get; init; } +} + +/// +/// Self-reported polyphony binary metadata. +/// is the SemVer-with-buildmetadata that MinVer writes; +/// is the numeric AssemblyVersion (stable across pre-releases). Both are +/// surfaced because operators frequently want to spot-check that a worktree +/// is running the binary they think it is (the AB#3064 dogfood was bitten by +/// a stale binary). +/// +public sealed record StatusBinarySection +{ + public required string Version { get; init; } + public required string InformationalVersion { get; init; } + public string? Location { get; init; } +} + +/// +/// Cross-signal detection. Codes are stable identifiers safe for routing +/// (planned_tag_zero_children, apex_not_in_scope, etc.); +/// is human-readable and may be surfaced verbatim +/// in dashboards. +/// +public sealed record StatusWarning +{ + public required string Code { get; init; } + public required string Message { get; init; } +} diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index 33ccc563..d6ee28e6 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -172,6 +172,12 @@ namespace Polyphony; [JsonSerializable(typeof(RequiredInputErrorResult))] [JsonSerializable(typeof(StateValidateInputsResult))] [JsonSerializable(typeof(StateValidateInputsDiagnostic))] +[JsonSerializable(typeof(StatusResult))] +[JsonSerializable(typeof(StatusAdoSection))] +[JsonSerializable(typeof(StatusManifestSection))] +[JsonSerializable(typeof(StatusFeaturePrSection))] +[JsonSerializable(typeof(StatusBinarySection))] +[JsonSerializable(typeof(StatusWarning))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/Polyphony/Program.cs b/src/Polyphony/Program.cs index fa843239..361e9b42 100644 --- a/src/Polyphony/Program.cs +++ b/src/Polyphony/Program.cs @@ -25,6 +25,7 @@ app.Add(); app.Add(); app.Add(); +app.Add(); app.Add("plan"); app.Add("policy"); app.Add("guidance"); @@ -49,7 +50,7 @@ // app.Add<...>() calls above. var knownVerbRoots = new HashSet(StringComparer.Ordinal) { - "validate", "validate-config", "hierarchy", "health", + "validate", "validate-config", "hierarchy", "health", "status", "plan", "policy", "guidance", "branch", "state", "pr", "scope", "root", "requirements", "mg", "manifest", "lock", "worktree", "worklist", "edges", "agent", // Built-ins / pass-throughs handled by CAF itself. diff --git a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs index e269df81..44d60d9a 100644 --- a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs +++ b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs @@ -1767,5 +1767,190 @@ public void PrGetCommentsAdoResult_OptionalFileLine_NullSuppressed() json.ShouldNotContain("\"published_at\""); json.ShouldNotContain("\"last_updated_at\""); } + + // ========================================================================= + // Status command — JSON contract + // (Routing-style: ALWAYS exits 0; no NotFound CacheError shape.) + // ========================================================================= + + [Fact] + public void Status_SnakeCaseFieldNames_PresentInRawJson() + { + var result = new StatusResult + { + ApexId = 42, + Ado = new StatusAdoSection + { + Found = true, + Type = "Issue", + State = "Doing", + Title = "Sample", + Tags = ["polyphony", "polyphony:root"], + InScope = true, + IsRoot = true, + HasPlannedTag = false, + ChildrenCount = 3, + }, + Manifest = new StatusManifestSection + { + Exists = true, + Path = ".polyphony/run.yaml", + FeatureBranch = "feature/42", + PlanGenerationsRoot = 1, + MergedPlanPrsCount = 1, + MergeGroupsCount = 0, + }, + FeaturePr = new StatusFeaturePrSection { Exists = false }, + Binary = new StatusBinarySection + { + Version = "1.0.0.0", + InformationalVersion = "1.2.3-alpha.0.4", + }, + Warnings = [], + Headline = "ok", + }; + + var json = JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.StatusResult); + + // Top-level snake_case keys. + json.ShouldContain("\"apex_id\""); + json.ShouldContain("\"ado\""); + json.ShouldContain("\"manifest\""); + json.ShouldContain("\"feature_pr\""); + json.ShouldContain("\"binary\""); + json.ShouldContain("\"warnings\""); + json.ShouldContain("\"headline\""); + // Per-section snake_case keys. + json.ShouldContain("\"has_planned_tag\""); + json.ShouldContain("\"is_root\""); + json.ShouldContain("\"in_scope\""); + json.ShouldContain("\"children_count\""); + json.ShouldContain("\"feature_branch\""); + json.ShouldContain("\"plan_generations_root\""); + json.ShouldContain("\"merged_plan_prs_count\""); + json.ShouldContain("\"merge_groups_count\""); + json.ShouldContain("\"informational_version\""); + + AssertNoPascalCase(json, "ApexId"); + AssertNoPascalCase(json, "HasPlannedTag"); + AssertNoPascalCase(json, "IsRoot"); + AssertNoPascalCase(json, "InScope"); + AssertNoPascalCase(json, "ChildrenCount"); + AssertNoPascalCase(json, "FeaturePr"); + AssertNoPascalCase(json, "FeatureBranch"); + AssertNoPascalCase(json, "PlanGenerationsRoot"); + AssertNoPascalCase(json, "MergedPlanPrsCount"); + AssertNoPascalCase(json, "MergeGroupsCount"); + AssertNoPascalCase(json, "InformationalVersion"); + } + + [Fact] + public void Status_NullFieldsOmitted_WhenWritingNull() + { + var result = new StatusResult + { + ApexId = 42, + Ado = new StatusAdoSection + { + Found = false, + Tags = [], + InScope = false, + IsRoot = false, + HasPlannedTag = false, + ChildrenCount = 0, + Error = "missing", + }, + Manifest = new StatusManifestSection { Exists = false }, + FeaturePr = new StatusFeaturePrSection { Exists = false }, + Binary = new StatusBinarySection + { + Version = "1.0.0.0", + InformationalVersion = "1.2.3", + Location = null, + }, + Warnings = [], + Headline = "missing", + NextAction = null, + }; + + var json = JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.StatusResult); + + // Optional/null fields must not appear in the wire output. + json.ShouldNotContain("\"next_action\""); + json.ShouldNotContain("\"location\""); + json.ShouldNotContain("\"type\":"); + json.ShouldNotContain("\"state\":"); + json.ShouldNotContain("\"title\":"); + json.ShouldNotContain("\"feature_branch\""); + json.ShouldNotContain("\"plan_generations_root\""); + json.ShouldNotContain("\"merged_plan_prs_count\""); + json.ShouldNotContain("\"merge_groups_count\""); + json.ShouldNotContain("\"path\":"); + json.ShouldNotContain("\"number\":"); + json.ShouldNotContain("\"url\":"); + json.ShouldNotContain("\"merged_at\":"); + } + + [Fact] + public void Status_DeserializationRoundTrip_FieldsMapped() + { + var original = new StatusResult + { + ApexId = 7, + Ado = new StatusAdoSection + { + Found = true, + Type = "Issue", + State = "Doing", + Title = "Roundtrip", + Tags = ["polyphony", "polyphony:root", "polyphony:planned"], + InScope = true, + IsRoot = true, + HasPlannedTag = true, + ChildrenCount = 0, + }, + Manifest = new StatusManifestSection + { + Exists = true, + Path = ".polyphony/run.yaml", + FeatureBranch = "feature/7", + PlanGenerationsRoot = 2, + MergedPlanPrsCount = 1, + MergeGroupsCount = 0, + }, + FeaturePr = new StatusFeaturePrSection + { + Exists = true, + Number = 99, + Url = "https://github.com/o/r/pull/99", + State = "OPEN", + }, + Binary = new StatusBinarySection + { + Version = "1.0.0.0", + InformationalVersion = "1.2.3", + Location = "/usr/local/bin/polyphony", + }, + Warnings = + [ + new StatusWarning { Code = "planned_tag_zero_children", Message = "msg" } + ], + Headline = "head", + NextAction = "next", + }; + + var json = JsonSerializer.Serialize(original, PolyphonyJsonContext.Default.StatusResult); + var rt = JsonSerializer.Deserialize(json, PolyphonyJsonContext.Default.StatusResult); + + rt.ShouldNotBeNull(); + rt.ApexId.ShouldBe(7); + rt.Ado.HasPlannedTag.ShouldBeTrue(); + rt.Ado.Tags.Count.ShouldBe(3); + rt.Manifest.PlanGenerationsRoot.ShouldBe(2); + rt.FeaturePr.Number.ShouldBe(99); + rt.Warnings.Count.ShouldBe(1); + rt.Warnings[0].Code.ShouldBe("planned_tag_zero_children"); + rt.NextAction.ShouldBe("next"); + } } diff --git a/tests/Polyphony.Tests/Commands/StatusCommandTests.cs b/tests/Polyphony.Tests/Commands/StatusCommandTests.cs new file mode 100644 index 00000000..4606e22a --- /dev/null +++ b/tests/Polyphony.Tests/Commands/StatusCommandTests.cs @@ -0,0 +1,345 @@ +using System.Text.Json; +using NSubstitute; +using Polyphony.Commands; +using Polyphony.Infrastructure.Processes; +using Polyphony.Manifest; +using Polyphony.Tests.TestFixtures; +using Shouldly; +using Xunit; + +namespace Polyphony.Tests.Commands; + +/// +/// Tests for — the routing-style dashboard verb. +/// Routing-style means: ALWAYS exits 0; failure modes flow through per-section +/// error fields and the cross-signal Warnings array. Tests pin +/// that contract plus the five warning codes the command currently computes. +/// +[Collection("CwdSerial")] +public sealed class StatusCommandTests : CommandTestBase +{ + private readonly IGitClient _git = Substitute.For(); + private readonly IGhClient _gh = Substitute.For(); + + public StatusCommandTests() + { + // Default: no remote URL → no slug → feature_pr section returns + // {exists:false, error:"no_slug"}. Tests that exercise the gh leg + // override this on the substitute directly. + _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + } + + private StatusCommand CreateCommand() => new(Repository, _git, _gh); + + [Fact] + public async Task Status_MissingApex_ReturnsRoutingFailure_AndDoesNotEmitJson() + { + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status()); + + exitCode.ShouldBe(ExitCodes.RoutingFailure); + output.ShouldNotContain("\"apex_id\""); + } + + [Fact] + public async Task Status_WorkItemNotFound_ReturnsResultWithFoundFalse_AndExitsZero() + { + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 999_999)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.ApexId.ShouldBe(999_999); + result.Ado.Found.ShouldBeFalse(); + result.Ado.Error.ShouldNotBeNull(); + result.Ado.Error!.ShouldContain("999999"); + result.Headline.ShouldContain("not found"); + } + + [Fact] + public async Task Status_PlannedTagWithZeroChildren_EmitsFalseSatisfiedWarning() + { + // The AB#3064 false-satisfied bug: planned tag stamped on an apex + // that has no children. Headline takes the warning's wording. + var apex = new WorkItemBuilder() + .WithId(3064) + .WithType("Issue") + .WithTitle("Test apex") + .WithState("Doing") + .WithTags("polyphony; polyphony:root; polyphony:planned") + .Build(); + await SeedAsync(apex); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 3064)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.Ado.HasPlannedTag.ShouldBeTrue(); + result.Ado.ChildrenCount.ShouldBe(0); + result.Warnings.ShouldContain(w => w.Code == "planned_tag_zero_children"); + result.Headline.ShouldContain("planned but no children"); + } + + [Fact] + public async Task Status_ApexNotInScope_EmitsNotInScopeWarning() + { + // An ADO work item that exists but doesn't carry the polyphony tag. + // The dashboard catches this — it usually means the operator pointed + // status at the wrong work item. + var apex = new WorkItemBuilder() + .WithId(7) + .WithType("Issue") + .WithTitle("Wrong target") + .WithState("To Do") + .Build(); + await SeedAsync(apex); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 7)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.Ado.InScope.ShouldBeFalse(); + result.Warnings.ShouldContain(w => w.Code == "apex_not_in_scope"); + result.Headline.ShouldContain("not in polyphony scope"); + result.NextAction.ShouldNotBeNull(); + result.NextAction!.ShouldContain("polyphony root declare"); + } + + [Fact] + public async Task Status_InScopeButNotRoot_EmitsNotRootWarning() + { + var apex = new WorkItemBuilder() + .WithId(42) + .WithType("Task") + .WithTitle("descendant") + .WithState("Doing") + .WithTags("polyphony") + .Build(); + await SeedAsync(apex); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 42)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.Ado.InScope.ShouldBeTrue(); + result.Ado.IsRoot.ShouldBeFalse(); + result.Warnings.ShouldContain(w => w.Code == "apex_not_root"); + } + + [Fact] + public async Task Status_ManifestMissing_EmitsManifestMissingWarning_AndStillExitsZero() + { + var apex = new WorkItemBuilder() + .WithId(100) + .WithType("Issue") + .WithTitle("Healthy apex") + .WithState("Doing") + .WithTags("polyphony; polyphony:root") + .Build(); + await SeedAsync(apex); + + using var tempDir = new TempDirectory(); + var missingManifest = Path.Combine(tempDir.Path, ".polyphony", "run.yaml"); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync( + () => cmd.Status(apex: 100, manifestPath: missingManifest)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.Manifest.Exists.ShouldBeFalse(); + result.Warnings.ShouldContain(w => w.Code == "manifest_missing"); + } + + [Fact] + public async Task Status_ManifestPresent_RootGenerationAndCountsSurfaced() + { + var apex = new WorkItemBuilder() + .WithId(200) + .WithType("Issue") + .WithTitle("Run-in-flight") + .WithState("Doing") + .WithTags("polyphony; polyphony:root") + .Build(); + await SeedAsync(apex); + + // Manifest exists, plan PRs merged, but no feature PR returned by gh. + // That's the unmerged-progress signal — pin both the surfaced fields + // and the warning. + _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("https://github.com/owner/repo")); + _gh.ListPullRequestsAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + + using var tempDir = new TempDirectory(); + var manifestPath = Path.Combine(tempDir.Path, ".polyphony", "run.yaml"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + File.WriteAllText(manifestPath, """ + schema: 1 + root_id: 200 + platform_project: dev.azure.com/test/Test + created_at: 2026-05-09T00:00:00Z + created_by: test + branch_model_version: 1 + plan_generations: + root: 3 + merged_plan_prs: + - pr_number: 1 + item_key: root + merge_commit: abc123 + previous_generation: 0 + current_generation: 1 + recorded_at: 2026-05-09T01:00:00Z + """); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync( + () => cmd.Status(apex: 200, manifestPath: manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + if (result.Manifest.Error is not null) + { + // Surface the parser's own message so a schema drift surfaces + // as the test's failure message rather than a downstream null. + throw new Xunit.Sdk.XunitException( + $"Manifest failed to parse: {result.Manifest.Error}"); + } + result.Manifest.Exists.ShouldBeTrue(); + result.Manifest.FeatureBranch.ShouldBe("feature/200"); + result.Manifest.PlanGenerationsRoot.ShouldBe(3); + result.Manifest.MergedPlanPrsCount.ShouldBe(1); + result.Warnings.ShouldContain(w => w.Code == "feature_pr_unmerged_progress"); + } + + [Fact] + public async Task Status_FeaturePrMerged_NoUnmergedProgressWarning_AndHeadlineReportsMerged() + { + var apex = new WorkItemBuilder() + .WithId(300) + .WithType("Issue") + .WithTitle("Shipped") + .WithState("Done") + .WithTags("polyphony; polyphony:root") + .Build(); + await SeedAsync(apex); + + using var tempDir = new TempDirectory(); + var manifestPath = Path.Combine(tempDir.Path, ".polyphony", "run.yaml"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + File.WriteAllText(manifestPath, """ + schema: 1 + root_id: 300 + platform_project: dev.azure.com/test/Test + created_at: 2026-05-09T00:00:00Z + created_by: test + branch_model_version: 1 + merged_plan_prs: + - pr_number: 1 + item_key: root + merge_commit: abc123 + previous_generation: 0 + current_generation: 1 + recorded_at: 2026-05-09T01:00:00Z + """); + + // gh reports a MERGED feature PR — exists+MergedAt populated. + _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("git@github.com:owner/repo.git")); + _gh.ListPullRequestsAsync( + "owner/repo", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([ + new PullRequestSummary( + Number: 42, + HeadRefName: "feature/300", + Url: "https://github.com/owner/repo/pull/42", + MergedAt: DateTimeOffset.Parse("2026-05-09T02:00:00Z")) + ])); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync( + () => cmd.Status(apex: 300, manifestPath: manifestPath)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.FeaturePr.Exists.ShouldBeTrue(); + result.FeaturePr.Number.ShouldBe(42); + result.FeaturePr.State.ShouldBe("MERGED"); + result.Warnings.ShouldNotContain(w => w.Code == "feature_pr_unmerged_progress"); + result.Headline.ShouldContain("merged"); + } + + [Fact] + public async Task Status_GhFails_FeaturePrSectionCarriesError_AndExitZero() + { + var apex = new WorkItemBuilder() + .WithId(400) + .WithType("Issue") + .WithTitle("gh wedged") + .WithState("Doing") + .WithTags("polyphony; polyphony:root") + .Build(); + await SeedAsync(apex); + + _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("https://github.com/owner/repo")); + _gh.ListPullRequestsAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("gh hung — buffered stderr: ...")); + + var cmd = CreateCommand(); + var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 400)); + + exitCode.ShouldBe(ExitCodes.Success); + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.FeaturePr.Exists.ShouldBeFalse(); + result.FeaturePr.Error.ShouldNotBeNull(); + result.FeaturePr.Error!.ShouldContain("gh hung"); + } + + [Fact] + public async Task Status_BinarySection_AlwaysPopulated() + { + var apex = new WorkItemBuilder().WithId(500).Build(); + await SeedAsync(apex); + + var cmd = CreateCommand(); + var (_, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 500)); + + var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult); + result.ShouldNotBeNull(); + result.Binary.Version.ShouldNotBeNullOrEmpty(); + result.Binary.InformationalVersion.ShouldNotBeNullOrEmpty(); + } + + /// Self-contained scratch directory that deletes on dispose. + private sealed class TempDirectory : IDisposable + { + public string Path { get; } + public TempDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "polyphony-status-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +}