Skip to main content

plan_issue/commands/
mod.rs

1pub mod build;
2pub mod completion;
3pub mod plan;
4pub mod record;
5pub mod sprint;
6pub mod tracking;
7
8use clap::{Args, Subcommand, ValueEnum};
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::{ValidationError, issue_body};
13
14use self::build::{BuildPlanTaskSpecArgs, BuildTaskSpecArgs};
15use self::completion::CompletionArgs;
16use self::plan::{
17    CleanupWorktreesArgs, ClosePlanArgs, LinkPrArgs, ReadyPlanArgs, ResolveApprovalArgs,
18    StartPlanArgs, StatusPlanArgs,
19};
20use self::record::RecordArgs;
21use self::sprint::{AcceptSprintArgs, MultiSprintGuideArgs, ReadySprintArgs, StartSprintArgs};
22use self::tracking::TrackingArgs;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
25pub enum PrGrouping {
26    #[value(name = "per-sprint", alias = "per-spring")]
27    PerSprint,
28    #[value(name = "group")]
29    Group,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
33pub enum SplitStrategy {
34    #[value(name = "deterministic")]
35    Deterministic,
36    #[value(name = "auto")]
37    Auto,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct PrGroupMapping {
42    pub task: String,
43    pub group: String,
44}
45
46fn parse_pr_group_mapping(raw: &str) -> Result<PrGroupMapping, String> {
47    let (task_raw, group_raw) = raw
48        .split_once('=')
49        .ok_or_else(|| "expected format <task>=<group>".to_string())?;
50
51    let task = task_raw.trim();
52    let group = group_raw.trim();
53
54    if task.is_empty() {
55        return Err("task key in --pr-group cannot be empty".to_string());
56    }
57    if group.is_empty() {
58        return Err("group name in --pr-group cannot be empty".to_string());
59    }
60
61    Ok(PrGroupMapping {
62        task: task.to_string(),
63        group: group.to_string(),
64    })
65}
66
67#[derive(Debug, Clone, Args, Serialize)]
68pub struct PrefixArgs {
69    /// Task owner prefix.
70    #[arg(long, default_value = "subagent", value_name = "text")]
71    pub owner_prefix: String,
72
73    /// Branch prefix. Defaults to `feat` so dispatch lane branches pass the
74    /// `forge-cli` Conventional Commits prefix rule
75    /// (`feat|fix|chore|docs|ci|refactor`).
76    #[arg(long, default_value = "feat", value_name = "text")]
77    pub branch_prefix: String,
78
79    /// Worktree prefix.
80    #[arg(long, default_value = "feat__", value_name = "text")]
81    pub worktree_prefix: String,
82}
83
84#[derive(Debug, Clone, Args, Serialize)]
85pub struct GroupingArgs {
86    /// Split strategy for group assignment.
87    #[arg(
88        long,
89        value_enum,
90        default_value_t = SplitStrategy::Deterministic,
91        value_name = "strategy"
92    )]
93    pub strategy: SplitStrategy,
94
95    /// PR grouping mode (deterministic only).
96    #[arg(long, value_enum, value_name = "mode")]
97    pub pr_grouping: Option<PrGrouping>,
98
99    /// Auto fallback when sprint metadata omits grouping intent.
100    #[arg(long = "default-pr-grouping", value_enum, value_name = "mode")]
101    pub default_pr_grouping: Option<PrGrouping>,
102
103    /// Explicit task->group mapping (`<task>=<group>`). Repeatable.
104    #[arg(
105        long = "pr-group",
106        value_name = "task=group",
107        value_parser = parse_pr_group_mapping
108    )]
109    pub pr_group: Vec<PrGroupMapping>,
110}
111
112#[derive(Debug, Clone, Args, Default, Serialize)]
113pub struct SummaryArgs {
114    /// Inline review summary text.
115    #[arg(long, conflicts_with = "summary_file", value_name = "text")]
116    pub summary: Option<String>,
117
118    /// Path to markdown/text review summary.
119    #[arg(long, conflicts_with = "summary", value_name = "path")]
120    pub summary_file: Option<std::path::PathBuf>,
121}
122
123#[derive(Debug, Clone, Args, Default, Serialize)]
124pub struct CommentModeArgs {
125    /// Emit comment output.
126    #[arg(long, conflicts_with = "no_comment")]
127    pub comment: bool,
128
129    /// Disable comment output.
130    #[arg(long = "no-comment", conflicts_with = "comment")]
131    pub no_comment: bool,
132}
133
134#[derive(Debug, Clone, Args, Default, Serialize)]
135pub struct CommentTextArgs {
136    /// Inline close comment.
137    #[arg(long, conflicts_with = "comment_file", value_name = "text")]
138    pub comment: Option<String>,
139
140    /// Path to close comment markdown/text.
141    #[arg(long, conflicts_with = "comment", value_name = "path")]
142    pub comment_file: Option<std::path::PathBuf>,
143}
144
145#[derive(Debug, Clone, Subcommand)]
146pub enum Command {
147    /// Build sprint-scoped task-spec TSV from a plan.
148    BuildTaskSpec(BuildTaskSpecArgs),
149
150    /// Build plan-scoped task-spec TSV (all sprints) for the single plan issue.
151    BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
152
153    /// Open one plan issue with all plan tasks in Task Decomposition.
154    StartPlan(StartPlanArgs),
155
156    /// Wrapper of issue-delivery-loop status for the plan issue.
157    StatusPlan(StatusPlanArgs),
158
159    /// Link PR to task rows and set runtime status (default: in-progress).
160    LinkPr(LinkPrArgs),
161
162    /// Wrapper of issue-delivery-loop ready-for-review for final plan review.
163    ReadyPlan(ReadyPlanArgs),
164
165    /// Close the single plan issue after final approval + merged PR gates, then enforce worktree cleanup.
166    ClosePlan(ClosePlanArgs),
167
168    /// Enforce cleanup of all issue-assigned task worktrees.
169    CleanupWorktrees(CleanupWorktreesArgs),
170
171    /// Start sprint from Task Decomposition runtime truth after previous sprint merge+done gate passes.
172    StartSprint(StartSprintArgs),
173
174    /// Post sprint-ready comment for main-agent review before merge.
175    ReadySprint(ReadySprintArgs),
176
177    /// Enforce merged-PR gate, sync sprint status=done, then post accepted comment.
178    AcceptSprint(AcceptSprintArgs),
179
180    /// Print the full repeated command flow for a plan (1 plan = 1 issue).
181    MultiSprintGuide(MultiSprintGuideArgs),
182
183    /// Resolve the URL of the most recent `Decision: merge` review-evidence
184    /// comment on a PR, suitable for `accept-sprint --approved-comment-url`.
185    ResolveApproval(ResolveApprovalArgs),
186
187    /// Render and audit issue-backed plan record dashboards and comments.
188    Record(RecordArgs),
189
190    /// Run-state controller for the plan-tracking issue workflow
191    /// (`status`, `run init`, `run update`, `checkpoint`, `close-ready`).
192    Tracking(TrackingArgs),
193
194    /// Export shell completion script.
195    Completion(CompletionArgs),
196}
197
198impl Command {
199    pub fn command_id(&self) -> &'static str {
200        match self {
201            Self::BuildTaskSpec(_) => "build-task-spec",
202            Self::BuildPlanTaskSpec(_) => "build-plan-task-spec",
203            Self::StartPlan(_) => "start-plan",
204            Self::StatusPlan(_) => "status-plan",
205            Self::LinkPr(_) => "link-pr",
206            Self::ReadyPlan(_) => "ready-plan",
207            Self::ClosePlan(_) => "close-plan",
208            Self::CleanupWorktrees(_) => "cleanup-worktrees",
209            Self::StartSprint(_) => "start-sprint",
210            Self::ReadySprint(_) => "ready-sprint",
211            Self::AcceptSprint(_) => "accept-sprint",
212            Self::MultiSprintGuide(_) => "multi-sprint-guide",
213            Self::ResolveApproval(_) => "resolve-approval",
214            Self::Record(args) => args.command_id(),
215            Self::Tracking(args) => args.command_id(),
216            Self::Completion(_) => "completion",
217        }
218    }
219
220    pub fn schema_version(&self) -> String {
221        // Most commands stay on `.v1`. Commands whose `result` payload picked
222        // up new orchestrator-friendly fields in Sprint 1 (`repo_slug`,
223        // `pr_groups`, `worktree_abs_path`) bump to `.v2`. Existing v1
224        // readers that read only the older fields are still compatible —
225        // the new fields are additive — but should be considered deprecated
226        // and updated to the v2 schema.
227        let suffix = match self {
228            // Result now exposes `repo_slug` (Task 1.1).
229            Self::StartPlan(_) => "v2",
230            // Result now exposes `repo_slug` (Task 1.1).
231            Self::StatusPlan(_) => "v2",
232            // Result now exposes `repo_slug` (Task 1.1) + `pr_groups`
233            // (Task 1.3); dispatch records gain `worktree_abs_path`
234            // (Task 1.4).
235            Self::StartSprint(_) => "v2",
236            // v2 lifecycle record subcommand surface (Sprint 3): live
237            // open/post/repair-dashboard/close, structured payloads, and
238            // strict closeout gate. The result envelope grew live operation
239            // fields (`issue.url`, `comments.*`, `closeout_url`,
240            // `final_dashboard`).
241            Self::Record(_) => "v2",
242            Self::Tracking(_) => "v1",
243            _ => "v1",
244        };
245        format!(
246            "plan-issue.{}.{suffix}",
247            self.command_id().replace('-', ".")
248        )
249    }
250
251    pub fn payload(&self) -> Value {
252        let payload = match self {
253            Self::BuildTaskSpec(args) => serde_json::to_value(args),
254            Self::BuildPlanTaskSpec(args) => serde_json::to_value(args),
255            Self::StartPlan(args) => serde_json::to_value(args),
256            Self::StatusPlan(args) => serde_json::to_value(args),
257            Self::LinkPr(args) => serde_json::to_value(args),
258            Self::ReadyPlan(args) => serde_json::to_value(args),
259            Self::ClosePlan(args) => serde_json::to_value(args),
260            Self::CleanupWorktrees(args) => serde_json::to_value(args),
261            Self::StartSprint(args) => serde_json::to_value(args),
262            Self::ReadySprint(args) => serde_json::to_value(args),
263            Self::AcceptSprint(args) => serde_json::to_value(args),
264            Self::MultiSprintGuide(args) => serde_json::to_value(args),
265            Self::ResolveApproval(args) => serde_json::to_value(args),
266            Self::Record(args) => serde_json::to_value(args),
267            Self::Tracking(args) => serde_json::to_value(args),
268            Self::Completion(args) => serde_json::to_value(args),
269        };
270
271        payload.unwrap_or(Value::Null)
272    }
273
274    pub fn validate(&self, dry_run: bool) -> Result<(), ValidationError> {
275        match self {
276            Self::BuildTaskSpec(args) => validate_grouping(&args.grouping),
277            Self::BuildPlanTaskSpec(args) => validate_grouping(&args.grouping),
278            Self::StartPlan(args) => validate_grouping(&args.grouping),
279            // Sprint commands may infer `--strategy` / `--default-pr-grouping`
280            // from the plan markdown's `pr-grouping` metadata at runtime
281            // (Task 1.2), so the no-flag deterministic path is intentionally
282            // permitted here; the runtime resolver enforces the real
283            // requirement once the plan has been read.
284            Self::StartSprint(args) => validate_grouping_with_plan_inference(&args.grouping),
285            Self::ReadySprint(args) => validate_grouping_with_plan_inference(&args.grouping),
286            Self::AcceptSprint(args) => validate_grouping_with_plan_inference(&args.grouping),
287            Self::ClosePlan(args) => validate_close_plan_args(args, dry_run),
288            Self::LinkPr(args) => validate_link_pr_args(args),
289            Self::MultiSprintGuide(args) => validate_multi_sprint_guide_args(args),
290            Self::Record(_) => Ok(()),
291            Self::Tracking(_) => Ok(()),
292            Self::Completion(_)
293            | Self::StatusPlan(_)
294            | Self::ReadyPlan(_)
295            | Self::ResolveApproval(_)
296            | Self::CleanupWorktrees(_) => Ok(()),
297        }
298    }
299}
300
301impl RecordArgs {
302    pub fn command_id(&self) -> &'static str {
303        match &self.command {
304            record::RecordCommand::Open(_) => "record.open",
305            record::RecordCommand::Attach(_) => "record.attach",
306            record::RecordCommand::Post(_) => "record.post",
307            record::RecordCommand::RepairDashboard(_) => "record.repair-dashboard",
308            record::RecordCommand::Close(_) => "record.close",
309            record::RecordCommand::Audit(_) => "record.audit",
310            record::RecordCommand::Template(_) => "record.template",
311            record::RecordCommand::Restore(_) => "record.restore",
312        }
313    }
314}
315
316impl TrackingArgs {
317    pub fn command_id(&self) -> &'static str {
318        match &self.command {
319            tracking::TrackingCommand::Status(_) => "tracking.status",
320            tracking::TrackingCommand::Run(run) => match &run.command {
321                tracking::TrackingRunCommand::Init(_) => "tracking.run.init",
322                tracking::TrackingRunCommand::Update(_) => "tracking.run.update",
323            },
324            tracking::TrackingCommand::Checkpoint(_) => "tracking.checkpoint",
325            tracking::TrackingCommand::CloseReady(_) => "tracking.close-ready",
326        }
327    }
328}
329
330fn validate_grouping(grouping: &GroupingArgs) -> Result<(), ValidationError> {
331    match grouping.strategy {
332        SplitStrategy::Deterministic => {
333            let Some(pr_grouping) = grouping.pr_grouping else {
334                return Err(ValidationError::new(
335                    "invalid-pr-grouping",
336                    "--strategy deterministic requires --pr-grouping <per-sprint|group>",
337                ));
338            };
339            if grouping.default_pr_grouping.is_some() {
340                return Err(ValidationError::new(
341                    "invalid-pr-grouping",
342                    "--default-pr-grouping is only valid when --strategy auto",
343                ));
344            }
345            match (pr_grouping, grouping.pr_group.is_empty()) {
346                (PrGrouping::PerSprint, false) => Err(ValidationError::new(
347                    "invalid-pr-grouping",
348                    "--pr-group is only valid when --pr-grouping group",
349                )),
350                (PrGrouping::Group, true) => Err(ValidationError::new(
351                    "invalid-pr-grouping",
352                    "--pr-grouping group with --strategy deterministic requires --pr-group mappings",
353                )),
354                _ => Ok(()),
355            }
356        }
357        SplitStrategy::Auto => {
358            if grouping.pr_grouping.is_some() {
359                return Err(ValidationError::new(
360                    "invalid-pr-grouping",
361                    "--pr-grouping cannot be used with --strategy auto; use sprint metadata or --default-pr-grouping",
362                ));
363            }
364            Ok(())
365        }
366    }
367}
368
369/// Validate grouping for sprint commands that may infer flags from the
370/// plan's per-sprint `pr-grouping` metadata (Task 1.2). Identical to
371/// `validate_grouping` except the "deterministic with no `--pr-grouping`"
372/// case is permitted: the runtime resolver in `execute::run_*_sprint`
373/// either substitutes plan-derived defaults or surfaces a richer error.
374fn validate_grouping_with_plan_inference(grouping: &GroupingArgs) -> Result<(), ValidationError> {
375    // No-flag path that downstream inference will fill in.
376    if grouping.strategy == SplitStrategy::Deterministic
377        && grouping.pr_grouping.is_none()
378        && grouping.default_pr_grouping.is_none()
379        && grouping.pr_group.is_empty()
380    {
381        return Ok(());
382    }
383    validate_grouping(grouping)
384}
385
386fn validate_close_plan_args(args: &ClosePlanArgs, dry_run: bool) -> Result<(), ValidationError> {
387    if args.issue.is_some() && args.body_file.is_some() {
388        return Err(ValidationError::new(
389            "conflicting-issue-source",
390            "use either --issue or --body-file for close-plan, not both",
391        ));
392    }
393
394    if dry_run && args.body_file.is_none() {
395        return Err(ValidationError::new(
396            "missing-body-file",
397            "--body-file is required for close-plan --dry-run",
398        ));
399    }
400
401    if !dry_run && args.issue.is_none() {
402        return Err(ValidationError::new(
403            "missing-issue",
404            "--issue is required for close-plan",
405        ));
406    }
407
408    if !dry_run && args.body_file.is_some() {
409        return Err(ValidationError::new(
410            "invalid-body-file-mode",
411            "--body-file is only supported with --dry-run",
412        ));
413    }
414
415    Ok(())
416}
417
418fn validate_link_pr_args(args: &LinkPrArgs) -> Result<(), ValidationError> {
419    let pr = args.pr.trim();
420    if issue_body::parse_pr_number(pr).is_none() {
421        return Err(ValidationError::new(
422            "invalid-pr-reference",
423            "--pr must be a concrete PR reference (`#123`, `123`, or GitHub pull URL)",
424        ));
425    }
426
427    if let Some(task) = args.task.as_deref()
428        && task.trim().is_empty()
429    {
430        return Err(ValidationError::new(
431            "invalid-task-id",
432            "--task cannot be empty",
433        ));
434    }
435
436    if let Some(group) = args.pr_group.as_deref()
437        && group.trim().is_empty()
438    {
439        return Err(ValidationError::new(
440            "invalid-pr-group",
441            "--pr-group cannot be empty",
442        ));
443    }
444
445    Ok(())
446}
447
448fn validate_multi_sprint_guide_args(args: &MultiSprintGuideArgs) -> Result<(), ValidationError> {
449    if let Some(to_sprint) = args.to_sprint
450        && to_sprint < args.from_sprint
451    {
452        return Err(ValidationError::new(
453            "invalid-sprint-range",
454            "--from-sprint must be <= --to-sprint",
455        ));
456    }
457    Ok(())
458}