diff --git a/src/Polyphony/Commands/BranchCommands.LoadTree.cs b/src/Polyphony/Commands/BranchCommands.LoadTree.cs index 78ae7b6c..0d37b91c 100644 --- a/src/Polyphony/Commands/BranchCommands.LoadTree.cs +++ b/src/Polyphony/Commands/BranchCommands.LoadTree.cs @@ -8,9 +8,11 @@ namespace Polyphony.Commands; /// /// polyphony branch load-tree: discover the work-item hierarchy -/// rooted at the given epic, group children into PR groups via PG-N tags, -/// match each PG to its merged PR (if any), and emit completion + -/// reconciliation summaries. Migrated from scripts/load-work-tree.ps1. +/// rooted at the given epic, group children into merge groups via +/// PG-N tags (legacy tag format until prompt-migration PR +/// lands), match each merge group to its merged PR (if any), and emit +/// completion + reconciliation summaries. Migrated from +/// scripts/load-work-tree.ps1. /// public sealed partial class BranchCommands { @@ -19,7 +21,8 @@ public sealed partial class BranchCommands /// /// Loads the work-item tree rooted at , - /// discovers PR groups, and reports completion + reconciliation status. + /// discovers merge groups, and reports completion + reconciliation + /// status. /// /// ADO work item ID — root of the hierarchy (typically an epic). /// Cancellation token. @@ -48,14 +51,14 @@ public async Task LoadTree(int workItem, CancellationToken ct = default) var repoSlug = await TryResolveRepoSlugAsync(ct).ConfigureAwait(false); var mergedPrs = await TryListMergedPrsAsync(repoSlug, ct).ConfigureAwait(false); - var prGroups = BuildPrGroups(hierarchy, allItems, mergedPrs); + var mergeGroups = BuildMergeGroups(hierarchy, allItems, mergedPrs); - var completed = prGroups.Where(p => p.Completed).Select(p => p.Name).ToList(); - var pending = prGroups.Where(p => !p.Completed).Select(p => p.Name).ToList(); - var nextPg = pending.Count > 0 ? pending[0] : ""; - var reconcile = prGroups + var completed = mergeGroups.Where(p => p.Completed).Select(p => p.Name).ToList(); + var pending = mergeGroups.Where(p => !p.Completed).Select(p => p.Name).ToList(); + var nextMergeGroup = pending.Count > 0 ? pending[0] : ""; + var reconcile = mergeGroups .Where(p => p.NeedsReconciliation) - .Select(p => new PgReconciliation + .Select(p => new MergeGroupReconciliation { Name = p.Name, NonDoneChildIds = p.NonDoneChildIds, @@ -64,7 +67,7 @@ public async Task LoadTree(int workItem, CancellationToken ct = default) }) .ToList(); - var taggedCount = allItems.Count(i => ExtractPgTag(i.Tags) is not null); + var taggedCount = allItems.Count(i => ExtractLegacyPgTag(i.Tags) is not null); var totalTasks = allItems.Count(i => i.Facets.Contains("implementable") && !i.Facets.Contains("plannable")); var totalIssues = allItems.Count(i => i.Facets.Contains("plannable")); @@ -76,11 +79,11 @@ public async Task LoadTree(int workItem, CancellationToken ct = default) result = new BranchLoadTreeResult { WorkTree = workTree, - PrGroups = prGroups, - CompletedPgs = completed, - PendingPgs = pending, - NextPg = nextPg, - PgsNeedingReconciliation = reconcile, + MergeGroups = mergeGroups, + CompletedMergeGroups = completed, + PendingMergeGroups = pending, + NextMergeGroup = nextMergeGroup, + MergeGroupsNeedingReconciliation = reconcile, TotalTasks = totalTasks, TotalIssues = totalIssues, TaggedItems = taggedCount, @@ -137,23 +140,23 @@ private static WorkTree BuildWorkTree(HierarchyResult root) }; } - private static IReadOnlyList BuildPrGroups( + private static IReadOnlyList BuildMergeGroups( HierarchyResult root, IReadOnlyList allItems, IReadOnlyList mergedPrs) { - // Group items by their PG-N tag — replicates Group-ByPG semantics - // from scripts/lib/pg-helpers.ps1. - var pgMap = new Dictionary Implementable, List Container)>(StringComparer.Ordinal); + // Group items by their PG-N tag (legacy planner-emitted format) — + // replicates Group-ByPG semantics from scripts/lib/pg-helpers.ps1. + var groupMap = new Dictionary Implementable, List Container)>(StringComparer.Ordinal); foreach (var item in allItems) { - var tag = ExtractPgTag(item.Tags); + var tag = ExtractLegacyPgTag(item.Tags); if (tag is null) continue; - if (!pgMap.TryGetValue(tag, out var bucket)) + if (!groupMap.TryGetValue(tag, out var bucket)) { bucket = (new List(), new List()); - pgMap[tag] = bucket; + groupMap[tag] = bucket; } var isImplementable = item.Facets.Contains("implementable"); @@ -170,8 +173,8 @@ private static IReadOnlyList BuildPrGroups( } } - var groups = new List(); - if (pgMap.Count == 0) + var groups = new List(); + if (groupMap.Count == 0) { // Fallback: no PG tags found → synthesize a single PG-1. var slug = Slugify(root.Title); @@ -181,16 +184,16 @@ private static IReadOnlyList BuildPrGroups( var issueIds = allItems .Where(i => i.Facets.Contains("plannable")) .Select(i => i.WorkItemId).ToList(); - groups.Add(BuildOnePg("PG-1", taskIds, issueIds, NewBranchName($"pg-1-{slug}"), + groups.Add(BuildOneMergeGroup("PG-1", taskIds, issueIds, NewBranchName($"pg-1-{slug}"), allItems, mergedPrs, isFallback: true)); } else { - foreach (var name in pgMap.Keys.OrderBy(SortKeyForPg)) + foreach (var name in groupMap.Keys.OrderBy(SortKeyForLegacyPgTag)) { - var bucket = pgMap[name]; + var bucket = groupMap[name]; var branch = NewBranchName(Slugify(name)); - groups.Add(BuildOnePg(name, bucket.Implementable, bucket.Container, branch, + groups.Add(BuildOneMergeGroup(name, bucket.Implementable, bucket.Container, branch, allItems, mergedPrs, isFallback: false)); } } @@ -198,7 +201,7 @@ private static IReadOnlyList BuildPrGroups( return groups; } - private static PullRequestGroup BuildOnePg( + private static MergeGroup BuildOneMergeGroup( string name, IReadOnlyList taskIds, IReadOnlyList issueIds, @@ -211,12 +214,13 @@ private static PullRequestGroup BuildOnePg( var mergedPr = match?.Number ?? 0; var allIds = taskIds.Concat(issueIds).ToHashSet(); - var pgItems = allItems.Where(i => allIds.Contains(i.WorkItemId)).ToList(); - var allDone = pgItems.Count > 0 && pgItems.All(i => IsTerminalCategory(i.State)); + var groupItems = allItems.Where(i => allIds.Contains(i.WorkItemId)).ToList(); + var allDone = groupItems.Count > 0 && groupItems.All(i => IsTerminalCategory(i.State)); - // Fallback PG only counts as "completed" once both the PR is merged - // AND every item is terminal — preserves the load-work-tree.ps1 - // semantic where ungrouped scopes need both signals to close. + // Fallback merge group only counts as "completed" once both the PR + // is merged AND every item is terminal — preserves the + // load-work-tree.ps1 semantic where ungrouped scopes need both + // signals to close. var completed = isFallback ? mergedPr > 0 && allDone : mergedPr > 0; IReadOnlyList nonDoneChildren = []; @@ -238,7 +242,7 @@ private static PullRequestGroup BuildOnePg( .Select(i => i.WorkItemId).ToList(); } - return new PullRequestGroup + return new MergeGroup { Name = name, ChildIds = taskIds, @@ -253,11 +257,11 @@ private static PullRequestGroup BuildOnePg( }; } - private static int SortKeyForPg(string pg) + private static int SortKeyForLegacyPgTag(string tag) { // "PG-3" → 3; non-conforming names sort last. - if (pg.StartsWith("PG-", StringComparison.Ordinal) - && int.TryParse(pg[3..], out var n)) + if (tag.StartsWith("PG-", StringComparison.Ordinal) + && int.TryParse(tag[3..], out var n)) { return n; } @@ -309,11 +313,11 @@ private async Task> TryListMergedPrsAsync( private static BranchLoadTreeResult EmptyLoadTreeResult(int workItem, string error, string adoWorkspace) => new() { WorkTree = new WorkTree { EpicId = workItem, EpicTitle = "", EpicType = "", WorkItems = [] }, - PrGroups = [], - CompletedPgs = [], - PendingPgs = [], - NextPg = "", - PgsNeedingReconciliation = [], + MergeGroups = [], + CompletedMergeGroups = [], + PendingMergeGroups = [], + NextMergeGroup = "", + MergeGroupsNeedingReconciliation = [], TotalTasks = 0, TotalIssues = 0, TaggedItems = 0, diff --git a/src/Polyphony/Commands/BranchCommands.NextTask.cs b/src/Polyphony/Commands/BranchCommands.NextTask.cs index 92745cf3..d4cd7f42 100644 --- a/src/Polyphony/Commands/BranchCommands.NextTask.cs +++ b/src/Polyphony/Commands/BranchCommands.NextTask.cs @@ -7,19 +7,20 @@ namespace Polyphony.Commands; /// /// polyphony branch next-task: select the next implementable item -/// in a PG and transition it to its in-progress state. +/// in a merge group and transition it to its in-progress state. /// Migrated from scripts/task-router.ps1. /// public sealed partial class BranchCommands { /// - /// Picks the next non-terminal implementable item in the named PG, - /// transitions it via begin_implementation, and emits the - /// branch name + workspace metadata the workflow needs to start work. + /// Picks the next non-terminal implementable item in the named merge + /// group, transitions it via begin_implementation, and emits + /// the branch name + workspace metadata the workflow needs to start + /// work. /// /// ADO work item ID — root of the hierarchy. - /// PG name (e.g. "PG-1"). Either this or pg-number is required. - /// PG number (e.g. 1). Convenience for callers that track PG as int. + /// Merge-group name (e.g. "PG-1"). Either this or pg-number is required. Operator-facing flag name preserved as --pg-name until the workflow rewire PR ships. + /// Merge-group number (e.g. 1). Convenience for callers that track merge groups as ints. /// Cancellation token. [Command("next-task")] public async Task NextTask( @@ -28,11 +29,11 @@ public async Task NextTask( int pgNumber = 0, CancellationToken ct = default) { - var resolvedPg = string.IsNullOrEmpty(pgName) && pgNumber > 0 + var resolvedMergeGroup = string.IsNullOrEmpty(pgName) && pgNumber > 0 ? $"PG-{pgNumber}" : pgName; - if (string.IsNullOrEmpty(resolvedPg)) + if (string.IsNullOrEmpty(resolvedMergeGroup)) { EmitNextTask(EmptyNextTaskResult("Either --pg-name or --pg-number must be provided.", "", "")); return ExitCodes.Success; @@ -46,7 +47,7 @@ public async Task NextTask( var hierarchy = await walker.WalkAsync(workItem, maxDepth: 3, ct).ConfigureAwait(false); if (hierarchy is null) { - EmitNextTask(EmptyNextTaskResult($"Work item {workItem} not found", resolvedPg, + EmitNextTask(EmptyNextTaskResult($"Work item {workItem} not found", resolvedMergeGroup, await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false))); return ExitCodes.Success; } @@ -59,23 +60,23 @@ public async Task NextTask( var implementable = nodes.Where(n => n.Node.Facets.Contains("implementable")).ToList(); // Same fallback ladder as task-router.ps1: - // 1. items directly tagged with the PG - // 2. items whose parent container is tagged with the PG + // 1. items directly tagged with the merge group + // 2. items whose parent container is tagged with the merge group // 3. issue-as-task: plannable+implementable, tagged, no children // 4. all implementable items var candidates = implementable - .Where(n => string.Equals(ExtractPgTag(n.Node.Tags), resolvedPg, StringComparison.Ordinal)) + .Where(n => string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) .ToList(); if (candidates.Count == 0) { - var pgContainerIds = nodes + var mergeGroupContainerIds = nodes .Where(n => n.Node.Facets.Contains("plannable") - && string.Equals(ExtractPgTag(n.Node.Tags), resolvedPg, StringComparison.Ordinal)) + && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal)) .Select(n => n.Node.WorkItemId) .ToHashSet(); candidates = implementable - .Where(n => n.Parent is not null && pgContainerIds.Contains(n.Parent.Node.WorkItemId)) + .Where(n => n.Parent is not null && mergeGroupContainerIds.Contains(n.Parent.Node.WorkItemId)) .ToList(); } @@ -85,7 +86,7 @@ public async Task NextTask( .Where(n => n.Node.Facets.Contains("plannable") && n.Node.Facets.Contains("implementable") - && string.Equals(ExtractPgTag(n.Node.Tags), resolvedPg, StringComparison.Ordinal) + && string.Equals(ExtractLegacyPgTag(n.Node.Tags), resolvedMergeGroup, StringComparison.Ordinal) && (n.Node.Children is null || n.Node.Children.Length == 0)) .ToList(); } @@ -110,7 +111,7 @@ public async Task NextTask( ContainerTitle = "", ContainerType = "", RemainingCount = 0, - CurrentPg = resolvedPg, + CurrentMergeGroup = resolvedMergeGroup, BranchName = "", AdoWorkspace = workspace, }; @@ -144,12 +145,13 @@ public async Task NextTask( // Walk up to find the nearest plannable ancestor (the container). var (containerId, containerTitle, containerType) = FindNearestPlannableAncestorWithType(next); - // Resolve branch name: prefer config-driven workspace_hint.pg_branch - // pattern, fall back to feature/{rootId}-{slug-of-pg}. + // Resolve branch name: prefer config-driven workspace_hint + // merge-group-branch pattern (legacy JSON wire key + // "pg_branch"), fall back to feature/{rootId}-{slug-of-mg}. var rootItem = await repository.GetByIdAsync(workItem, ct).ConfigureAwait(false); var hint = rootItem is not null ? BranchNameResolver.Resolve(processConfig, rootItem) : null; - var branchName = await ResolveBranchNameAsync(hint, resolvedPg, workItem, ct).ConfigureAwait(false); + var branchName = await ResolveBranchNameAsync(hint, resolvedMergeGroup, workItem, ct).ConfigureAwait(false); result = new BranchNextTaskResult { @@ -161,7 +163,7 @@ public async Task NextTask( ContainerTitle = containerTitle, ContainerType = containerType, RemainingCount = nonTerminal.Count, - CurrentPg = resolvedPg, + CurrentMergeGroup = resolvedMergeGroup, BranchName = branchName, AdoWorkspace = workspace, }; @@ -170,7 +172,7 @@ public async Task NextTask( catch (Exception ex) { result = EmptyNextTaskResult($"Error routing next task: {ex.Message}", - resolvedPg, await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false)); + resolvedMergeGroup, await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false)); } EmitNextTask(result); @@ -178,21 +180,23 @@ public async Task NextTask( } private async Task ResolveBranchNameAsync( - WorkspaceHint? hint, string pgName, int rootId, CancellationToken ct) + WorkspaceHint? hint, string mergeGroupName, int rootId, CancellationToken ct) { string expected; - if (hint is { PgBranch: { Length: > 0 } pgBranchTemplate }) + if (hint is { MergeGroupBranch: { Length: > 0 } template }) { - // Substitute {n} OR {pg} (the PG number) into the configured - // pg_branch template — accept both conventions used in the wild. - var pgNum = ExtractPgNumber(pgName); - expected = pgBranchTemplate - .Replace("{n}", pgNum, StringComparison.OrdinalIgnoreCase) - .Replace("{pg}", pgNum, StringComparison.OrdinalIgnoreCase); + // Substitute {n} OR {pg} (the merge-group number) into the + // configured branch template — accept both conventions used + // in the wild. Legacy template tokens preserved until the + // workflow rewire PR ships. + var num = ExtractLegacyPgNumber(mergeGroupName); + expected = template + .Replace("{n}", num, StringComparison.OrdinalIgnoreCase) + .Replace("{pg}", num, StringComparison.OrdinalIgnoreCase); } else { - var slug = Slugify(pgName); + var slug = Slugify(mergeGroupName); expected = $"feature/{rootId}-{slug}"; if (expected.Length > 60) expected = expected[..60]; } @@ -203,10 +207,16 @@ private async Task ResolveBranchNameAsync( return string.Equals(current, expected, StringComparison.Ordinal) ? current : expected; } - private static string ExtractPgNumber(string pgName) + /// + /// Parses the legacy PG-N merge-group name format to extract + /// the numeric suffix. "Legacy" because the planner-emitted format + /// is scheduled to flip in the prompt-migration PR; until then this + /// reader only accepts PG-N. + /// + private static string ExtractLegacyPgNumber(string mergeGroupName) { - if (pgName.StartsWith("PG-", StringComparison.Ordinal) - && int.TryParse(pgName[3..], out var n)) + if (mergeGroupName.StartsWith("PG-", StringComparison.Ordinal) + && int.TryParse(mergeGroupName[3..], out var n)) { return n.ToString(); } @@ -247,7 +257,7 @@ private static IEnumerable FlattenWithParents(HierarchyResult no private sealed record NodeWithParent(HierarchyResult Node, NodeWithParent? Parent); - private static BranchNextTaskResult EmptyNextTaskResult(string error, string pg, string adoWorkspace) => new() + private static BranchNextTaskResult EmptyNextTaskResult(string error, string mergeGroup, string adoWorkspace) => new() { Action = "error", PrimaryId = 0, @@ -257,7 +267,7 @@ private sealed record NodeWithParent(HierarchyResult Node, NodeWithParent? Paren ContainerTitle = "", ContainerType = "", RemainingCount = 0, - CurrentPg = pg, + CurrentMergeGroup = mergeGroup, BranchName = "", AdoWorkspace = adoWorkspace, Error = error, diff --git a/src/Polyphony/Commands/BranchCommands.Route.cs b/src/Polyphony/Commands/BranchCommands.Route.cs index a6ff1170..c99c1033 100644 --- a/src/Polyphony/Commands/BranchCommands.Route.cs +++ b/src/Polyphony/Commands/BranchCommands.Route.cs @@ -9,7 +9,7 @@ namespace Polyphony.Commands; /// -/// polyphony branch route: classifies every PR group in the +/// polyphony branch route: classifies every merge group in the /// hierarchy and reports the next action the workflow should take — /// create_branch, submit_pr, or all_complete. Migrated from /// scripts/pg-router.ps1. @@ -17,14 +17,17 @@ namespace Polyphony.Commands; public sealed partial class BranchCommands { /// - /// Routes a work-item hierarchy to its next PR-group action. + /// Routes a work-item hierarchy to its next merge-group action. /// /// ADO work item ID — root of the hierarchy. /// - /// Optional PG number (e.g. 2) to scope routing to. When supplied, - /// the named PG is selected as current_pg regardless of - /// whether earlier PGs are still incomplete — required for parallel - /// for_each dispatch where each invocation must route on its own PG. + /// Optional merge-group number (e.g. 2) to scope routing to. When + /// supplied, the named merge group is selected as current_pg + /// (legacy JSON wire key) regardless of whether earlier merge groups + /// are still incomplete — required for parallel for_each dispatch + /// where each invocation must route on its own merge group. + /// Operator-facing flag name preserved as --pg-number until + /// the workflow rewire PR ships. /// /// Cancellation token. [Command("route")] @@ -47,7 +50,7 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c var rootItem = await repository.GetByIdAsync(workItem, ct).ConfigureAwait(false); var hint = rootItem is not null ? BranchNameResolver.Resolve(processConfig, rootItem) : null; - // Build PG groups (or a single fallback PG-1 when no tags exist). + // Build merge groups (or a single fallback PG-1 when no tags exist). var groups = BuildRouteGroups(workItem, hierarchy, allItems, hint); // Resolve repo + remote branches + PR lists; degrade gracefully on failure. @@ -58,8 +61,8 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c var classified = ClassifyGroups(groups, allItems, remoteBranches, mergedPrs, openPrs); - // PG scoping: parallel dispatch overrides "first non-completed". - ClassifiedPg? current; + // Merge-group scoping: parallel dispatch overrides "first non-completed". + ClassifiedMergeGroup? current; if (pgNumber > 0) { current = classified @@ -75,23 +78,23 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c } var workspace = await ResolveAdoWorkspaceAsync(ct).ConfigureAwait(false); - var completedPgs = classified.Where(c => c.Completed).Select(c => c.Group.Name).ToList(); - var remainingPgs = classified.Where(c => !c.Completed).Select(c => c.Group.Name).ToList(); + var completedMergeGroups = classified.Where(c => c.Completed).Select(c => c.Group.Name).ToList(); + var remainingMergeGroups = classified.Where(c => !c.Completed).Select(c => c.Group.Name).ToList(); if (current is null) { result = new BranchRouteResult { Action = "all_complete", - CurrentPg = "", + CurrentMergeGroup = "", BranchName = "", WorkItemIds = [], ChildIds = [], PrNumber = 0, PrUrl = "", - CompletedPgs = completedPgs, - RemainingPgs = [], - TotalPgs = classified.Count, + CompletedMergeGroups = completedMergeGroups, + RemainingMergeGroups = [], + TotalMergeGroups = classified.Count, AdoWorkspace = workspace, }; } @@ -100,15 +103,15 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c result = new BranchRouteResult { Action = current.Action, - CurrentPg = current.Group.Name, + CurrentMergeGroup = current.Group.Name, BranchName = current.Group.BranchName, WorkItemIds = current.Group.WorkItemIds, ChildIds = current.Group.ChildIds, PrNumber = current.PrNumber, PrUrl = current.PrUrl, - CompletedPgs = completedPgs, - RemainingPgs = remainingPgs, - TotalPgs = classified.Count, + CompletedMergeGroups = completedMergeGroups, + RemainingMergeGroups = remainingMergeGroups, + TotalMergeGroups = classified.Count, AdoWorkspace = workspace, }; } @@ -116,7 +119,7 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c catch (OperationCanceledException) { throw; } catch (Exception ex) { - result = EmptyRouteResult($"Error routing PG: {ex.Message}", + result = EmptyRouteResult($"Error routing merge group: {ex.Message}", await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false)); } @@ -124,22 +127,22 @@ public async Task Route(int workItem, int pgNumber = 0, CancellationToken c return ExitCodes.Success; } - private List BuildRouteGroups( + private List BuildRouteGroups( int rootId, HierarchyResult root, IReadOnlyList allItems, WorkspaceHint? hint) { - var pgMap = new Dictionary Implementable, List Container)>(StringComparer.Ordinal); + var mergeGroupMap = new Dictionary Implementable, List Container)>(StringComparer.Ordinal); foreach (var item in allItems) { - var tag = ExtractPgTag(item.Tags); + var tag = ExtractLegacyPgTag(item.Tags); if (tag is null) continue; - if (!pgMap.TryGetValue(tag, out var bucket)) + if (!mergeGroupMap.TryGetValue(tag, out var bucket)) { bucket = (new List(), new List()); - pgMap[tag] = bucket; + mergeGroupMap[tag] = bucket; } var isImplementable = item.Facets.Contains("implementable"); @@ -156,8 +159,8 @@ private List BuildRouteGroups( } } - var groups = new List(); - if (pgMap.Count == 0) + var groups = new List(); + if (mergeGroupMap.Count == 0) { // Fallback: synthesize a single PG-1 from the whole tree. var taskIds = allItems @@ -178,7 +181,7 @@ private List BuildRouteGroups( return pln && (!imp || hc); }) .Select(i => i.WorkItemId).ToList(); - groups.Add(new RoutePgGroup( + groups.Add(new RouteMergeGroup( Name: "PG-1", BranchName: ResolveFeatureBranch(hint, rootId, root.Title), ChildIds: taskIds, @@ -186,12 +189,12 @@ private List BuildRouteGroups( } else { - foreach (var name in pgMap.Keys.OrderBy(SortKeyForPg)) + foreach (var name in mergeGroupMap.Keys.OrderBy(SortKeyForLegacyPgTag)) { - var bucket = pgMap[name]; - groups.Add(new RoutePgGroup( + var bucket = mergeGroupMap[name]; + groups.Add(new RouteMergeGroup( Name: name, - BranchName: ResolvePgBranch(hint, rootId, name), + BranchName: ResolveLegacyMergeGroupBranch(hint, rootId, name), ChildIds: bucket.Implementable, WorkItemIds: bucket.Container)); } @@ -200,28 +203,28 @@ private List BuildRouteGroups( return groups; } - private static List ClassifyGroups( - IReadOnlyList groups, + private static List ClassifyGroups( + IReadOnlyList groups, IReadOnlyList allItems, IReadOnlyList remoteBranches, IReadOnlyList mergedPrs, IReadOnlyList openPrs) { - var classified = new List(groups.Count); - foreach (var pg in groups) + var classified = new List(groups.Count); + foreach (var mg in groups) { var mergedPr = mergedPrs.FirstOrDefault(p => - string.Equals(p.HeadRefName, pg.BranchName, StringComparison.Ordinal)); + string.Equals(p.HeadRefName, mg.BranchName, StringComparison.Ordinal)); var openPr = openPrs.FirstOrDefault(p => - string.Equals(p.HeadRefName, pg.BranchName, StringComparison.Ordinal)); + string.Equals(p.HeadRefName, mg.BranchName, StringComparison.Ordinal)); if (mergedPr is not null) { // Stale-branch defense: a merged PR with all containers still // in the proposed/initial category is most likely a leftover // from a prior failed run. - var stale = pg.WorkItemIds.Count > 0 - && !pg.WorkItemIds.Any(id => + var stale = mg.WorkItemIds.Count > 0 + && !mg.WorkItemIds.Any(id => { var item = allItems.FirstOrDefault(i => i.WorkItemId == id); if (item is null) return false; @@ -231,35 +234,35 @@ private static List ClassifyGroups( if (!stale) { - classified.Add(new ClassifiedPg(pg, "all_complete", true, + classified.Add(new ClassifiedMergeGroup(mg, "all_complete", true, mergedPr.Number, mergedPr.Url ?? "")); continue; } - classified.Add(new ClassifiedPg(pg, "create_branch", false, 0, "")); + classified.Add(new ClassifiedMergeGroup(mg, "create_branch", false, 0, "")); continue; } if (openPr is not null) { - classified.Add(new ClassifiedPg(pg, "submit_pr", false, + classified.Add(new ClassifiedMergeGroup(mg, "submit_pr", false, openPr.Number, openPr.Url ?? "")); continue; } // No merged or open PR — fall back to ADO-state-only completion. // Prefer issue states when present; else use task states. - var allDone = pg.WorkItemIds.Count > 0 - ? pg.WorkItemIds.All(id => IsItemTerminal(id, allItems)) - : pg.ChildIds.Count > 0 && pg.ChildIds.All(id => IsItemTerminal(id, allItems)); + var allDone = mg.WorkItemIds.Count > 0 + ? mg.WorkItemIds.All(id => IsItemTerminal(id, allItems)) + : mg.ChildIds.Count > 0 && mg.ChildIds.All(id => IsItemTerminal(id, allItems)); if (allDone) { - classified.Add(new ClassifiedPg(pg, "all_complete", true, 0, "")); + classified.Add(new ClassifiedMergeGroup(mg, "all_complete", true, 0, "")); } else { - classified.Add(new ClassifiedPg(pg, "create_branch", false, 0, "")); + classified.Add(new ClassifiedMergeGroup(mg, "create_branch", false, 0, "")); } } return classified; @@ -271,16 +274,24 @@ private static bool IsItemTerminal(int id, IReadOnlyList allIte return item is not null && IsTerminalCategory(item.State); } - private static string ResolvePgBranch(WorkspaceHint? hint, int rootId, string pgName) + /// + /// Resolves the merge-group branch name by substituting the legacy + /// {n}/{pg} placeholders in the user's + /// pg_branch template against the PG-N tag suffix. + /// "Legacy" because the template surface and tag format both flip in + /// the workflow rewire PR; until then this still consumes + /// WorkspaceHint.MergeGroupBranch as a free-form template. + /// + private static string ResolveLegacyMergeGroupBranch(WorkspaceHint? hint, int rootId, string mergeGroupName) { - if (hint is { PgBranch: { Length: > 0 } template }) + if (hint is { MergeGroupBranch: { Length: > 0 } template }) { - var pgNum = ExtractPgNumber(pgName); + var num = ExtractLegacyPgNumber(mergeGroupName); return template - .Replace("{n}", pgNum, StringComparison.OrdinalIgnoreCase) - .Replace("{pg}", pgNum, StringComparison.OrdinalIgnoreCase); + .Replace("{n}", num, StringComparison.OrdinalIgnoreCase) + .Replace("{pg}", num, StringComparison.OrdinalIgnoreCase); } - var slug = Slugify(pgName); + var slug = Slugify(mergeGroupName); var b = $"feature/{rootId}-{slug}"; return b.Length > 60 ? b[..60] : b; } @@ -338,15 +349,15 @@ private async Task TryListRemoteSlugAsync(CancellationToken ct) private static BranchRouteResult EmptyRouteResult(string error, string adoWorkspace) => new() { Action = "error", - CurrentPg = "", + CurrentMergeGroup = "", BranchName = "", WorkItemIds = [], ChildIds = [], PrNumber = 0, PrUrl = "", - CompletedPgs = [], - RemainingPgs = [], - TotalPgs = 0, + CompletedMergeGroups = [], + RemainingMergeGroups = [], + TotalMergeGroups = 0, AdoWorkspace = adoWorkspace, Error = error, }; @@ -355,14 +366,14 @@ private static void EmitRoute(BranchRouteResult result) => Console.WriteLine(JsonSerializer.Serialize( result, PolyphonyJsonContext.Default.BranchRouteResult)); - private sealed record RoutePgGroup( + private sealed record RouteMergeGroup( string Name, string BranchName, IReadOnlyList ChildIds, IReadOnlyList WorkItemIds); - private sealed record ClassifiedPg( - RoutePgGroup Group, + private sealed record ClassifiedMergeGroup( + RouteMergeGroup Group, string Action, bool Completed, int PrNumber, diff --git a/src/Polyphony/Commands/BranchCommands.cs b/src/Polyphony/Commands/BranchCommands.cs index 3bc4c0a6..a9c9b3d1 100644 --- a/src/Polyphony/Commands/BranchCommands.cs +++ b/src/Polyphony/Commands/BranchCommands.cs @@ -228,13 +228,13 @@ private static void Emit(BranchCheckDepsResult result) PolyphonyJsonContext.Default.BranchCheckDepsResult)); /// - /// Close all non-terminal items in a PG scope by validating each - /// transition against the process config and transitioning valid items - /// to their target state. Replaces scripts/scope-closer.ps1. + /// Close all non-terminal items in a merge-group scope by validating + /// each transition against the process config and transitioning valid + /// items to their target state. Replaces scripts/scope-closer.ps1. /// /// ADO work item ID — root of the hierarchy. - /// PG name (e.g. "PG-1"). Either this or pg-number is required. - /// PG number (e.g. 1). Convenience for callers that track PG as int. + /// Merge-group name (e.g. "PG-1"). Either this or pg-number is required. Operator-facing flag name preserved as --pg-name until the workflow rewire PR ships. + /// Merge-group number (e.g. 1). Convenience for callers that track merge groups as ints. /// PR number associated with this scope closure (echoed in output). /// Cancellation token. [Command("close-scope")] @@ -245,11 +245,11 @@ public async Task CloseScope( int prNumber = 0, CancellationToken ct = default) { - var resolvedPg = string.IsNullOrEmpty(pgName) && pgNumber > 0 + var resolvedMergeGroup = string.IsNullOrEmpty(pgName) && pgNumber > 0 ? $"PG-{pgNumber}" : pgName; - if (string.IsNullOrEmpty(resolvedPg)) + if (string.IsNullOrEmpty(resolvedMergeGroup)) { EmitClose(EmptyClose("", prNumber, "Either --pg-name or --pg-number must be provided.", "")); return ExitCodes.Success; @@ -263,18 +263,18 @@ public async Task CloseScope( var hierarchy = await walker.WalkAsync(workItem, maxDepth: 3, ct).ConfigureAwait(false); if (hierarchy is null) { - EmitClose(EmptyClose(resolvedPg, prNumber, + EmitClose(EmptyClose(resolvedMergeGroup, prNumber, $"Work item {workItem} not found", await ResolveAdoWorkspaceAsync(ct).ConfigureAwait(false))); return ExitCodes.Success; } var allItems = Flatten(hierarchy).ToList(); - var pgItemIds = ItemsForPg(allItems, resolvedPg); - var pgItems = allItems.Where(i => pgItemIds.Contains(i.WorkItemId)).ToList(); + var mergeGroupItemIds = ItemsForMergeGroup(allItems, resolvedMergeGroup); + var mergeGroupItems = allItems.Where(i => mergeGroupItemIds.Contains(i.WorkItemId)).ToList(); // P5-compliant terminal check via twig.Domain (replaces hardcoded // 'Done' string in scope-closer.ps1:63). - var nonTerminal = pgItems.Where(i => !IsTerminalCategory(i.State)).ToList(); + var nonTerminal = mergeGroupItems.Where(i => !IsTerminalCategory(i.State)).ToList(); var closed = new List(); var failed = new List(); @@ -308,7 +308,7 @@ public async Task CloseScope( result = new BranchCloseScopeResult { - PgName = resolvedPg, + MergeGroupName = resolvedMergeGroup, PrNumber = prNumber, ClosedItems = closed, FailedClosures = failed, @@ -323,7 +323,7 @@ public async Task CloseScope( } catch (Exception ex) { - result = EmptyClose(resolvedPg, prNumber, $"Error closing scope: {ex.Message}", + result = EmptyClose(resolvedMergeGroup, prNumber, $"Error closing scope: {ex.Message}", await TryResolveAdoWorkspaceAsync(ct).ConfigureAwait(false)); } @@ -362,26 +362,33 @@ private async Task TryResolveAdoWorkspaceAsync(CancellationToken ct) } /// - /// Group-by-PG logic from scripts/lib/pg-helpers.ps1: - /// - Parse the work item's tags for the first PG-N entry. + /// Group-by-merge-group logic from scripts/lib/pg-helpers.ps1: + /// - Parse the work item's tags for the first PG-N entry + /// (legacy tag format — see ). /// - Classify by facet: implementable items go to implementables, /// plannable-with-children items go to containers. /// "Issue-as-task" (plannable+implementable with no children) is treated /// as implementable. /// - private static HashSet ItemsForPg(IEnumerable items, string pg) + private static HashSet ItemsForMergeGroup(IEnumerable items, string mergeGroup) { var ids = new HashSet(); foreach (var item in items) { - var tag = ExtractPgTag(item.Tags); - if (!string.Equals(tag, pg, StringComparison.Ordinal)) continue; + var tag = ExtractLegacyPgTag(item.Tags); + if (!string.Equals(tag, mergeGroup, StringComparison.Ordinal)) continue; ids.Add(item.WorkItemId); } return ids; } - private static string? ExtractPgTag(string? tags) + /// + /// Parses the legacy PG-N tag format from a work item's tag + /// string. Named "legacy" because the planner-emitted format is + /// scheduled to flip in the prompt-migration PR; until then this + /// reader only accepts PG-N. + /// + private static string? ExtractLegacyPgTag(string? tags) { if (string.IsNullOrEmpty(tags)) return null; foreach (var raw in tags.Split(';')) @@ -417,9 +424,9 @@ private static bool IsTerminalCategory(string state) return category == StateCategory.Completed || category == StateCategory.Removed; } - private static BranchCloseScopeResult EmptyClose(string pg, int pr, string error, string adoWorkspace) => new() + private static BranchCloseScopeResult EmptyClose(string mergeGroup, int pr, string error, string adoWorkspace) => new() { - PgName = pg, + MergeGroupName = mergeGroup, PrNumber = pr, ClosedItems = [], FailedClosures = [], diff --git a/src/Polyphony/Models/BranchCloseScopeResult.cs b/src/Polyphony/Models/BranchCloseScopeResult.cs index 249b9e5f..b364834b 100644 --- a/src/Polyphony/Models/BranchCloseScopeResult.cs +++ b/src/Polyphony/Models/BranchCloseScopeResult.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Polyphony; /// An item that was successfully transitioned to a terminal state. @@ -18,11 +20,16 @@ public sealed record FailedClosure /// /// Result of polyphony branch close-scope. Mirrors the JSON shape -/// emitted by the legacy scripts/scope-closer.ps1. +/// emitted by the legacy scripts/scope-closer.ps1. JSON wire key +/// pg_name is preserved via +/// until the workflow rewire PR +/// ships. /// public sealed record BranchCloseScopeResult { - public required string PgName { get; init; } + [JsonPropertyName("pg_name")] + public required string MergeGroupName { get; init; } + public required int PrNumber { get; init; } public required IReadOnlyList ClosedItems { get; init; } public required IReadOnlyList FailedClosures { get; init; } diff --git a/src/Polyphony/Models/BranchLoadTreeResult.cs b/src/Polyphony/Models/BranchLoadTreeResult.cs index a8a61ca4..6550c599 100644 --- a/src/Polyphony/Models/BranchLoadTreeResult.cs +++ b/src/Polyphony/Models/BranchLoadTreeResult.cs @@ -34,8 +34,16 @@ public sealed record WorkTree public required IReadOnlyList WorkItems { get; init; } } -/// A discovered PR group with completion + reconciliation metadata. -public sealed record PullRequestGroup +/// +/// A discovered merge group (the unit of mergeable work — formerly "PG" / +/// "Pull-request Group") with completion + reconciliation metadata. The +/// JSON wire format still emits legacy snake_case keys (pr_groups, +/// completed_pgs, etc.) via +/// pinning so workflow YAMLs and reference scripts keep binding correctly +/// — the wire flip will land in the follow-up PR alongside workflow +/// rewires. +/// +public sealed record MergeGroup { [JsonPropertyName("child_ids")] public required IReadOnlyList ChildIds { get; init; } @@ -54,8 +62,11 @@ public sealed record PullRequestGroup public required bool NeedsReconciliation { get; init; } } -/// Reconciliation summary for a PG that has a merged PR but stale items. -public sealed record PgReconciliation +/// +/// Reconciliation summary for a merge group that has a merged PR but +/// stale items. +/// +public sealed record MergeGroupReconciliation { [JsonPropertyName("non_done_child_ids")] public required IReadOnlyList NonDoneChildIds { get; init; } @@ -64,22 +75,37 @@ public sealed record PgReconciliation [JsonPropertyName("non_done_work_item_ids")] public required IReadOnlyList NonDoneWorkItemIds { get; init; } public required string Name { get; init; } - } /// /// Result of polyphony branch load-tree. Mirrors the JSON shape of /// the legacy scripts/load-work-tree.ps1 so existing workflow YAML /// refs continue to bind correctly. +/// +/// JSON wire format: every *MergeGroup* property below is pinned +/// to its legacy *pg* snake_case key by +/// . The wire flip is deferred to +/// a follow-up PR that lands alongside the workflow YAML rewires. /// public sealed record BranchLoadTreeResult { public required WorkTree WorkTree { get; init; } - public required IReadOnlyList PrGroups { get; init; } - public required IReadOnlyList CompletedPgs { get; init; } - public required IReadOnlyList PendingPgs { get; init; } - public required string NextPg { get; init; } - public required IReadOnlyList PgsNeedingReconciliation { get; init; } + + [JsonPropertyName("pr_groups")] + public required IReadOnlyList MergeGroups { get; init; } + + [JsonPropertyName("completed_pgs")] + public required IReadOnlyList CompletedMergeGroups { get; init; } + + [JsonPropertyName("pending_pgs")] + public required IReadOnlyList PendingMergeGroups { get; init; } + + [JsonPropertyName("next_pg")] + public required string NextMergeGroup { get; init; } + + [JsonPropertyName("pgs_needing_reconciliation")] + public required IReadOnlyList MergeGroupsNeedingReconciliation { get; init; } + public required int TotalTasks { get; init; } public required int TotalIssues { get; init; } public required int TaggedItems { get; init; } diff --git a/src/Polyphony/Models/BranchNextTaskResult.cs b/src/Polyphony/Models/BranchNextTaskResult.cs index e654a42c..a412ec26 100644 --- a/src/Polyphony/Models/BranchNextTaskResult.cs +++ b/src/Polyphony/Models/BranchNextTaskResult.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace Polyphony; /// /// Result of polyphony branch next-task. Mirrors the JSON shape of -/// the legacy scripts/task-router.ps1. +/// the legacy scripts/task-router.ps1. JSON wire key +/// current_pg is preserved via +/// until the workflow rewire PR +/// ships. /// public sealed record BranchNextTaskResult { @@ -14,7 +19,10 @@ public sealed record BranchNextTaskResult public required string ContainerTitle { get; init; } public required string ContainerType { get; init; } public required int RemainingCount { get; init; } - public required string CurrentPg { get; init; } + + [JsonPropertyName("current_pg")] + public required string CurrentMergeGroup { get; init; } + public required string BranchName { get; init; } public required string AdoWorkspace { get; init; } public string? Error { get; init; } diff --git a/src/Polyphony/Models/BranchRouteResult.cs b/src/Polyphony/Models/BranchRouteResult.cs index 5f528e9d..f9c6a9de 100644 --- a/src/Polyphony/Models/BranchRouteResult.cs +++ b/src/Polyphony/Models/BranchRouteResult.cs @@ -16,38 +16,54 @@ public sealed record BranchRouteResult public required string Action { get; init; } /// - /// Name of the PG the action targets (e.g. "PG-1"). Empty when - /// is all_complete or error. + /// Name of the merge group the action targets (e.g. "PG-1"). Empty + /// when is all_complete or error. + /// JSON wire key remains current_pg until the workflow rewire + /// PR ships. /// - public required string CurrentPg { get; init; } + [JsonPropertyName("current_pg")] + public required string CurrentMergeGroup { get; init; } /// - /// Branch name suggestion for the PG. Empty when no PG is active. + /// Branch name suggestion for the merge group. Empty when no merge + /// group is active. /// public required string BranchName { get; init; } - /// Container item IDs (issues, etc.) belonging to the PG. + /// Container item IDs (issues, etc.) belonging to the merge group. [JsonPropertyName("work_item_ids")] public required IReadOnlyList WorkItemIds { get; init; } - /// Implementable item IDs (children) belonging to the PG. + /// Implementable item IDs (children) belonging to the merge group. [JsonPropertyName("child_ids")] public required IReadOnlyList ChildIds { get; init; } - /// Existing PR number associated with the PG branch (0 when none). + /// Existing PR number associated with the merge-group branch (0 when none). public required int PrNumber { get; init; } /// Existing PR URL when one was matched, else empty. public required string PrUrl { get; init; } - /// Names of PGs already complete (merged or all items terminal). - public required IReadOnlyList CompletedPgs { get; init; } + /// + /// Names of merge groups already complete (merged or all items + /// terminal). JSON wire key stays completed_pgs. + /// + [JsonPropertyName("completed_pgs")] + public required IReadOnlyList CompletedMergeGroups { get; init; } - /// Names of PGs still in flight. - public required IReadOnlyList RemainingPgs { get; init; } + /// + /// Names of merge groups still in flight. JSON wire key stays + /// remaining_pgs. + /// + [JsonPropertyName("remaining_pgs")] + public required IReadOnlyList RemainingMergeGroups { get; init; } - /// Total PG count discovered (1 in the unstructured fallback). - public required int TotalPgs { get; init; } + /// + /// Total merge-group count discovered (1 in the unstructured + /// fallback). JSON wire key stays total_pgs. + /// + [JsonPropertyName("total_pgs")] + public required int TotalMergeGroups { get; init; } /// Resolved ADO workspace identifier ("org/project"). public required string AdoWorkspace { get; init; } diff --git a/src/Polyphony/Models/RouteResult.cs b/src/Polyphony/Models/RouteResult.cs index 326e4664..f8823c3b 100644 --- a/src/Polyphony/Models/RouteResult.cs +++ b/src/Polyphony/Models/RouteResult.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Polyphony; public sealed record RouteResult @@ -9,8 +11,15 @@ public sealed record RouteResult public WorkspaceHint? WorkspaceHint { get; init; } } +/// +/// Branch-name hints emitted alongside routing decisions. JSON wire key +/// pg_branch is preserved via +/// until the workflow rewire PR ships. +/// public sealed record WorkspaceHint { public string? FeatureBranch { get; init; } - public string? PgBranch { get; init; } + + [JsonPropertyName("pg_branch")] + public string? MergeGroupBranch { get; init; } } diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs index fddc7835..910733f7 100644 --- a/src/Polyphony/PolyphonyJsonContext.cs +++ b/src/Polyphony/PolyphonyJsonContext.cs @@ -33,8 +33,8 @@ namespace Polyphony; [JsonSerializable(typeof(WorkTree))] [JsonSerializable(typeof(WorkTreeIssue))] [JsonSerializable(typeof(WorkTreeTask))] -[JsonSerializable(typeof(PullRequestGroup))] -[JsonSerializable(typeof(PgReconciliation))] +[JsonSerializable(typeof(MergeGroup))] +[JsonSerializable(typeof(MergeGroupReconciliation))] [JsonSerializable(typeof(BranchNextTaskResult))] [JsonSerializable(typeof(PlanSeedChildrenResult))] [JsonSerializable(typeof(SeedReconciliation))] diff --git a/src/Polyphony/Routing/BranchNameResolver.cs b/src/Polyphony/Routing/BranchNameResolver.cs index 194a12d2..c9886ce7 100644 --- a/src/Polyphony/Routing/BranchNameResolver.cs +++ b/src/Polyphony/Routing/BranchNameResolver.cs @@ -27,7 +27,12 @@ public static class BranchNameResolver return new WorkspaceHint { FeatureBranch = SubstitutePlaceholders(config.BranchStrategy.FeatureBranch, rootItem.Id, slug), - PgBranch = SubstitutePlaceholders(config.BranchStrategy.PgBranch, rootItem.Id, slug), + // BranchStrategy.PgBranch is the YAML wire key on the user's + // process-config.yaml — preserved until the workflow rewire + // PR ships. WorkspaceHint exposes it under the new + // MergeGroupBranch C# name; the JSON output still emits + // "pg_branch" via [JsonPropertyName]. + MergeGroupBranch = SubstitutePlaceholders(config.BranchStrategy.PgBranch, rootItem.Id, slug), }; } diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs index 33f30c49..2b9e1940 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs @@ -70,7 +70,7 @@ public async Task CloseScope_PgNumberDerivesPgName() var (_, output) = await CaptureConsoleAsync(() => cmd.CloseScope(workItem: 100, pgNumber: 3, prNumber: 42)); var result = Deserialize(output); - result.PgName.ShouldBe("PG-3"); + result.MergeGroupName.ShouldBe("PG-3"); result.PrNumber.ShouldBe(42); result.AdoWorkspace.ShouldBe("org/proj"); } @@ -97,7 +97,7 @@ public async Task CloseScope_PgItemsTransitioned_RecordedInClosedItems() exit.ShouldBe(ExitCodes.Success); var result = Deserialize(output); - result.PgName.ShouldBe("PG-1"); + result.MergeGroupName.ShouldBe("PG-1"); result.PrNumber.ShouldBe(7); result.TotalClosed.ShouldBe(2); result.TotalFailed.ShouldBe(0); diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs index 1dd1b0d2..f0daadcf 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs @@ -88,12 +88,12 @@ public async Task LoadTree_NoPgTags_FallsBackToSinglePg1() var (_, output) = await CaptureConsoleAsync(() => cmd.LoadTree(100)); var result = Deserialize(output); - result.PrGroups.Count.ShouldBe(1); - result.PrGroups[0].Name.ShouldBe("PG-1"); - result.PrGroups[0].MergedPr.ShouldBe(0); - result.PrGroups[0].Completed.ShouldBeFalse(); - result.PendingPgs.ShouldContain("PG-1"); - result.NextPg.ShouldBe("PG-1"); + result.MergeGroups.Count.ShouldBe(1); + result.MergeGroups[0].Name.ShouldBe("PG-1"); + result.MergeGroups[0].MergedPr.ShouldBe(0); + result.MergeGroups[0].Completed.ShouldBeFalse(); + result.PendingMergeGroups.ShouldContain("PG-1"); + result.NextMergeGroup.ShouldBe("PG-1"); result.TaggedItems.ShouldBe(0); result.UntaggedItems.ShouldBe(3); } @@ -121,12 +121,12 @@ public async Task LoadTree_PgTags_GroupsItemsByPg() var (_, output) = await CaptureConsoleAsync(() => cmd.LoadTree(100)); var result = Deserialize(output); - result.PrGroups.Count.ShouldBe(2); + result.MergeGroups.Count.ShouldBe(2); // Sorted by N (PG-1 before PG-2). - result.PrGroups[0].Name.ShouldBe("PG-1"); - result.PrGroups[1].Name.ShouldBe("PG-2"); - result.PrGroups[0].BranchNameSuggestion.ShouldStartWith("feature/pg-1"); - result.NextPg.ShouldBe("PG-1"); + result.MergeGroups[0].Name.ShouldBe("PG-1"); + result.MergeGroups[1].Name.ShouldBe("PG-2"); + result.MergeGroups[0].BranchNameSuggestion.ShouldStartWith("feature/pg-1"); + result.NextMergeGroup.ShouldBe("PG-1"); result.TaggedItems.ShouldBe(4); result.WorkTree.EpicId.ShouldBe(100); result.WorkTree.WorkItems.Count.ShouldBe(2); @@ -149,11 +149,11 @@ public async Task LoadTree_MergedPr_MarksPgCompleted() var (_, output) = await CaptureConsoleAsync(() => cmd.LoadTree(100)); var result = Deserialize(output); - var pg = result.PrGroups.Single(); + var pg = result.MergeGroups.Single(); pg.MergedPr.ShouldBe(42); pg.Completed.ShouldBeTrue(); - result.CompletedPgs.ShouldContain("PG-1"); - result.NextPg.ShouldBe(""); + result.CompletedMergeGroups.ShouldContain("PG-1"); + result.NextMergeGroup.ShouldBe(""); } [Fact] @@ -173,12 +173,12 @@ public async Task LoadTree_CompletedPgWithStaleDoingTasks_NeedsReconciliation() var (_, output) = await CaptureConsoleAsync(() => cmd.LoadTree(100)); var result = Deserialize(output); - var pg = result.PrGroups.Single(); + var pg = result.MergeGroups.Single(); pg.Completed.ShouldBeTrue(); pg.NeedsReconciliation.ShouldBeTrue(); pg.StaleDoingChildIds.ShouldContain(300); - result.PgsNeedingReconciliation.Count.ShouldBe(1); - result.PgsNeedingReconciliation[0].StaleDoingChildIds.ShouldContain(300); + result.MergeGroupsNeedingReconciliation.Count.ShouldBe(1); + result.MergeGroupsNeedingReconciliation[0].StaleDoingChildIds.ShouldContain(300); } [Fact] @@ -199,8 +199,8 @@ public async Task LoadTree_NoGitHubRemote_PgsRemainPending() var (_, output) = await CaptureConsoleAsync(() => cmd.LoadTree(100)); var result = Deserialize(output); - result.PrGroups.Single().MergedPr.ShouldBe(0); - result.PrGroups.Single().Completed.ShouldBeFalse(); + result.MergeGroups.Single().MergedPr.ShouldBe(0); + result.MergeGroups.Single().Completed.ShouldBeFalse(); } [Fact] diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsNextTaskTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsNextTaskTests.cs index 3b78d714..568e067d 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsNextTaskTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsNextTaskTests.cs @@ -94,7 +94,7 @@ public async Task NextTask_HappyPath_TransitionsFirstNonDoneTask() result.ContainerTitle.ShouldBe("Issue 1"); result.ContainerType.ShouldBe("Issue"); result.RemainingCount.ShouldBe(2); - result.CurrentPg.ShouldBe("PG-1"); + result.CurrentMergeGroup.ShouldBe("PG-1"); result.AdoWorkspace.ShouldBe("org/proj"); result.BranchName.ShouldStartWith("feature/100-"); } @@ -143,7 +143,7 @@ public async Task NextTask_PgNumberDerivesPgName() () => cmd.NextTask(workItem: 100, pgNumber: 3)); var result = Deserialize(output); - result.CurrentPg.ShouldBe("PG-3"); + result.CurrentMergeGroup.ShouldBe("PG-3"); result.PrimaryId.ShouldBe(300); } diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs index 7cd6734b..b4241736 100644 --- a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs +++ b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs @@ -87,9 +87,9 @@ public async Task Route_NoPg_NoBranchNoMergedPr_EmitsCreateBranchPg1() var result = Deserialize(output); result.Action.ShouldBe("create_branch", $"Output was: {output}"); - result.CurrentPg.ShouldBe("PG-1"); + result.CurrentMergeGroup.ShouldBe("PG-1"); result.ChildIds.ShouldContain(200); - result.TotalPgs.ShouldBe(1); + result.TotalMergeGroups.ShouldBe(1); } [Fact] @@ -114,7 +114,7 @@ public async Task Route_OpenPrMatchesBranch_EmitsSubmitPr() result.Action.ShouldBe("submit_pr", $"Output was: {output}"); result.PrNumber.ShouldBe(42); result.PrUrl.ShouldBe("https://example.com/pr/42"); - result.CurrentPg.ShouldBe("PG-1"); + result.CurrentMergeGroup.ShouldBe("PG-1"); } [Fact] @@ -139,8 +139,8 @@ public async Task Route_MergedPrAndItemsTerminal_EmitsAllComplete() var result = Deserialize(output); result.Action.ShouldBe("all_complete", $"Output was: {output}"); - result.CompletedPgs.ShouldContain("PG-1"); - result.RemainingPgs.ShouldBeEmpty(); + result.CompletedMergeGroups.ShouldContain("PG-1"); + result.RemainingMergeGroups.ShouldBeEmpty(); } [Fact] @@ -166,7 +166,7 @@ public async Task Route_MergedPrButContainerStillProposed_EmitsCreateBranchAsSta var result = Deserialize(output); result.Action.ShouldBe("create_branch", $"Output was: {output}"); - result.CurrentPg.ShouldBe("PG-1"); + result.CurrentMergeGroup.ShouldBe("PG-1"); } [Fact] @@ -190,9 +190,9 @@ public async Task Route_PgNumberScoping_OverridesFirstNonComplete() var (_, output) = await CaptureConsoleAsync(() => cmd.Route(workItem: 100, pgNumber: 2)); var result = Deserialize(output); - result.CurrentPg.ShouldBe("PG-2", $"Output was: {output}"); + result.CurrentMergeGroup.ShouldBe("PG-2", $"Output was: {output}"); result.ChildIds.ShouldContain(201); - result.TotalPgs.ShouldBe(2); + result.TotalMergeGroups.ShouldBe(2); } [Fact] @@ -215,8 +215,8 @@ public async Task Route_AllPgsComplete_EmitsAllComplete() var result = Deserialize(output); result.Action.ShouldBe("all_complete", $"Output was: {output}"); - result.CurrentPg.ShouldBe(""); - result.CompletedPgs.ShouldContain("PG-1"); + result.CurrentMergeGroup.ShouldBe(""); + result.CompletedMergeGroups.ShouldContain("PG-1"); } [Fact] diff --git a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs index 4924f4aa..108e7da2 100644 --- a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs +++ b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs @@ -125,6 +125,9 @@ await SeedAsync( AssertNoPascalCase(output, "Message"); AssertNoPascalCase(output, "WorkspaceHint"); AssertNoPascalCase(output, "FeatureBranch"); + // The C# property is now named MergeGroupBranch but the JSON wire + // key is still "pg_branch" — assert the new C# name doesn't leak. + AssertNoPascalCase(output, "MergeGroupBranch"); AssertNoPascalCase(output, "PgBranch"); } @@ -143,8 +146,9 @@ await SeedAsync( var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.RouteResult); result.ShouldNotBeNull(); - // If pg_branch is null, it should be absent from the raw JSON - if (result.WorkspaceHint?.PgBranch is null) + // If MergeGroupBranch (legacy JSON key "pg_branch") is null, it + // should be absent from the raw JSON. + if (result.WorkspaceHint?.MergeGroupBranch is null) { output.ShouldNotContain("\"pg_branch\""); } @@ -482,7 +486,7 @@ await SeedAsync( public void SchemaRenames_JsonContract_FieldsPresent() { // Arrange: create dummy objects for serialization - var prGroup = new Polyphony.PullRequestGroup + var mergeGroup = new Polyphony.MergeGroup { ChildIds = new[] { 1, 2 }, WorkItemIds = new[] { 10, 20 }, @@ -495,7 +499,7 @@ public void SchemaRenames_JsonContract_FieldsPresent() Completed = true, NeedsReconciliation = false }; - var pgRecon = new Polyphony.PgReconciliation + var mergeGroupRecon = new Polyphony.MergeGroupReconciliation { NonDoneChildIds = new[] { 5 }, StaleDoingChildIds = new[] { 6 }, @@ -517,46 +521,57 @@ public void SchemaRenames_JsonContract_FieldsPresent() var routeResult = new Polyphony.BranchRouteResult { Action = "create_branch", - CurrentPg = "PG-1", + CurrentMergeGroup = "PG-1", BranchName = "feature/pg-1", WorkItemIds = new[] { 10, 20 }, ChildIds = new[] { 1, 2 }, PrNumber = 1, PrUrl = "url", - CompletedPgs = new[] { "PG-1" }, - RemainingPgs = new[] { "PG-2" }, - TotalPgs = 2, + CompletedMergeGroups = new[] { "PG-1" }, + RemainingMergeGroups = new[] { "PG-2" }, + TotalMergeGroups = 2, AdoWorkspace = "org/proj", Error = null }; // Act - var prGroupJson = JsonSerializer.Serialize(prGroup, PolyphonyJsonContext.Default.PullRequestGroup); - var pgReconJson = JsonSerializer.Serialize(pgRecon, PolyphonyJsonContext.Default.PgReconciliation); + var mergeGroupJson = JsonSerializer.Serialize(mergeGroup, PolyphonyJsonContext.Default.MergeGroup); + var mergeGroupReconJson = JsonSerializer.Serialize(mergeGroupRecon, PolyphonyJsonContext.Default.MergeGroupReconciliation); var seedReconJson = JsonSerializer.Serialize(seedRecon, PolyphonyJsonContext.Default.SeedReconciliation); var seedErrorJson = JsonSerializer.Serialize(seedError, PolyphonyJsonContext.Default.SeedError); var routeResultJson = JsonSerializer.Serialize(routeResult, PolyphonyJsonContext.Default.BranchRouteResult); - // Assert: JSON field names are stable and correct - prGroupJson.ShouldContain("\"child_ids\""); - prGroupJson.ShouldContain("\"work_item_ids\""); - prGroupJson.ShouldContain("\"non_done_child_ids\""); - prGroupJson.ShouldContain("\"stale_doing_child_ids\""); - prGroupJson.ShouldContain("\"non_done_work_item_ids\""); - pgReconJson.ShouldContain("\"non_done_child_ids\""); - pgReconJson.ShouldContain("\"stale_doing_child_ids\""); - pgReconJson.ShouldContain("\"non_done_work_item_ids\""); + // Assert: JSON field names are stable and correct (legacy snake_case + // wire keys preserved via [JsonPropertyName] until the workflow + // rewire PR ships). + mergeGroupJson.ShouldContain("\"child_ids\""); + mergeGroupJson.ShouldContain("\"work_item_ids\""); + mergeGroupJson.ShouldContain("\"non_done_child_ids\""); + mergeGroupJson.ShouldContain("\"stale_doing_child_ids\""); + mergeGroupJson.ShouldContain("\"non_done_work_item_ids\""); + mergeGroupReconJson.ShouldContain("\"non_done_child_ids\""); + mergeGroupReconJson.ShouldContain("\"stale_doing_child_ids\""); + mergeGroupReconJson.ShouldContain("\"non_done_work_item_ids\""); seedReconJson.ShouldContain("\"child_id\""); seedErrorJson.ShouldContain("\"child_id\""); routeResultJson.ShouldContain("\"work_item_ids\""); routeResultJson.ShouldContain("\"child_ids\""); + // Bridge proof: the renamed C# properties still emit the legacy + // snake_case keys consumers expect. + routeResultJson.ShouldContain("\"current_pg\""); + routeResultJson.ShouldContain("\"completed_pgs\""); + routeResultJson.ShouldContain("\"remaining_pgs\""); + routeResultJson.ShouldContain("\"total_pgs\""); // Assert: C# property names are not leaked - prGroupJson.ShouldNotContain("NonDoneChildIds"); - prGroupJson.ShouldNotContain("NonDoneWorkItemIds"); - prGroupJson.ShouldNotContain("StaleDoingChildIds"); - prGroupJson.ShouldNotContain("ChildIds"); + mergeGroupJson.ShouldNotContain("NonDoneChildIds"); + mergeGroupJson.ShouldNotContain("NonDoneWorkItemIds"); + mergeGroupJson.ShouldNotContain("StaleDoingChildIds"); + mergeGroupJson.ShouldNotContain("ChildIds"); routeResultJson.ShouldNotContain("WorkItemIds"); routeResultJson.ShouldNotContain("ChildIds"); + routeResultJson.ShouldNotContain("CurrentMergeGroup"); + routeResultJson.ShouldNotContain("CompletedMergeGroups"); + routeResultJson.ShouldNotContain("RemainingMergeGroups"); } // ========================================================================= diff --git a/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs b/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs index 2b78ff94..fe0c5db3 100644 --- a/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs +++ b/tests/Polyphony.Tests/Routing/BranchNameResolverTests.cs @@ -56,7 +56,7 @@ public void Resolve_WithSlugPlaceholder_GeneratesSlugFromTitle() } [Fact] - public void Resolve_PgBranch_SubstitutesPlaceholders() + public void Resolve_MergeGroupBranch_SubstitutesPlaceholders() { var config = new ProcessConfigBuilder() .WithType("Epic", ["plannable"]) @@ -67,7 +67,10 @@ public void Resolve_PgBranch_SubstitutesPlaceholders() var hint = BranchNameResolver.Resolve(config, item); hint.ShouldNotBeNull(); - hint.PgBranch.ShouldBe("feature/50-pg-test"); + // The YAML config key (BranchStrategy.PgBranch) is preserved per + // the compatibility-bridge plan; the WorkspaceHint surface is now + // MergeGroupBranch even though the JSON wire key remains pg_branch. + hint.MergeGroupBranch.ShouldBe("feature/50-pg-test"); } [Fact]