Skip to main content

plan_issue_cli/commands/
mod.rs

1pub mod build;
2pub mod completion;
3pub mod plan;
4pub mod sprint;
5
6use clap::{Args, Subcommand, ValueEnum};
7use serde::Serialize;
8use serde_json::Value;
9
10use crate::{ValidationError, issue_body};
11
12use self::build::{BuildPlanTaskSpecArgs, BuildTaskSpecArgs};
13use self::completion::CompletionArgs;
14use self::plan::{
15    CleanupWorktreesArgs, ClosePlanArgs, LinkPrArgs, ReadyPlanArgs, StartPlanArgs, StatusPlanArgs,
16};
17use self::sprint::{AcceptSprintArgs, MultiSprintGuideArgs, ReadySprintArgs, StartSprintArgs};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
20pub enum PrGrouping {
21    #[value(name = "per-sprint", alias = "per-spring")]
22    PerSprint,
23    #[value(name = "group")]
24    Group,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
28pub enum SplitStrategy {
29    #[value(name = "deterministic")]
30    Deterministic,
31    #[value(name = "auto")]
32    Auto,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct PrGroupMapping {
37    pub task: String,
38    pub group: String,
39}
40
41fn parse_pr_group_mapping(raw: &str) -> Result<PrGroupMapping, String> {
42    let (task_raw, group_raw) = raw
43        .split_once('=')
44        .ok_or_else(|| "expected format <task>=<group>".to_string())?;
45
46    let task = task_raw.trim();
47    let group = group_raw.trim();
48
49    if task.is_empty() {
50        return Err("task key in --pr-group cannot be empty".to_string());
51    }
52    if group.is_empty() {
53        return Err("group name in --pr-group cannot be empty".to_string());
54    }
55
56    Ok(PrGroupMapping {
57        task: task.to_string(),
58        group: group.to_string(),
59    })
60}
61
62#[derive(Debug, Clone, Args, Serialize)]
63pub struct PrefixArgs {
64    /// Task owner prefix.
65    #[arg(long, default_value = "subagent", value_name = "text")]
66    pub owner_prefix: String,
67
68    /// Branch prefix.
69    #[arg(long, default_value = "issue", value_name = "text")]
70    pub branch_prefix: String,
71
72    /// Worktree prefix.
73    #[arg(long, default_value = "issue__", value_name = "text")]
74    pub worktree_prefix: String,
75}
76
77#[derive(Debug, Clone, Args, Serialize)]
78pub struct GroupingArgs {
79    /// Split strategy for group assignment.
80    #[arg(
81        long,
82        value_enum,
83        default_value_t = SplitStrategy::Deterministic,
84        value_name = "strategy"
85    )]
86    pub strategy: SplitStrategy,
87
88    /// PR grouping mode (deterministic only).
89    #[arg(long, value_enum, value_name = "mode")]
90    pub pr_grouping: Option<PrGrouping>,
91
92    /// Auto fallback when sprint metadata omits grouping intent.
93    #[arg(long = "default-pr-grouping", value_enum, value_name = "mode")]
94    pub default_pr_grouping: Option<PrGrouping>,
95
96    /// Explicit task->group mapping (`<task>=<group>`). Repeatable.
97    #[arg(
98        long = "pr-group",
99        value_name = "task=group",
100        value_parser = parse_pr_group_mapping
101    )]
102    pub pr_group: Vec<PrGroupMapping>,
103}
104
105#[derive(Debug, Clone, Args, Default, Serialize)]
106pub struct SummaryArgs {
107    /// Inline review summary text.
108    #[arg(long, conflicts_with = "summary_file", value_name = "text")]
109    pub summary: Option<String>,
110
111    /// Path to markdown/text review summary.
112    #[arg(long, conflicts_with = "summary", value_name = "path")]
113    pub summary_file: Option<std::path::PathBuf>,
114}
115
116#[derive(Debug, Clone, Args, Default, Serialize)]
117pub struct CommentModeArgs {
118    /// Emit comment output.
119    #[arg(long, conflicts_with = "no_comment")]
120    pub comment: bool,
121
122    /// Disable comment output.
123    #[arg(long = "no-comment", conflicts_with = "comment")]
124    pub no_comment: bool,
125}
126
127#[derive(Debug, Clone, Args, Default, Serialize)]
128pub struct CommentTextArgs {
129    /// Inline close comment.
130    #[arg(long, conflicts_with = "comment_file", value_name = "text")]
131    pub comment: Option<String>,
132
133    /// Path to close comment markdown/text.
134    #[arg(long, conflicts_with = "comment", value_name = "path")]
135    pub comment_file: Option<std::path::PathBuf>,
136}
137
138#[derive(Debug, Clone, Subcommand)]
139pub enum Command {
140    /// Build sprint-scoped task-spec TSV from a plan.
141    BuildTaskSpec(BuildTaskSpecArgs),
142
143    /// Build plan-scoped task-spec TSV (all sprints) for the single plan issue.
144    BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
145
146    /// Open one plan issue with all plan tasks in Task Decomposition.
147    StartPlan(StartPlanArgs),
148
149    /// Wrapper of issue-delivery-loop status for the plan issue.
150    StatusPlan(StatusPlanArgs),
151
152    /// Link PR to task rows and set runtime status (default: in-progress).
153    LinkPr(LinkPrArgs),
154
155    /// Wrapper of issue-delivery-loop ready-for-review for final plan review.
156    ReadyPlan(ReadyPlanArgs),
157
158    /// Close the single plan issue after final approval + merged PR gates, then enforce worktree cleanup.
159    ClosePlan(ClosePlanArgs),
160
161    /// Enforce cleanup of all issue-assigned task worktrees.
162    CleanupWorktrees(CleanupWorktreesArgs),
163
164    /// Start sprint from Task Decomposition runtime truth after previous sprint merge+done gate passes.
165    StartSprint(StartSprintArgs),
166
167    /// Post sprint-ready comment for main-agent review before merge.
168    ReadySprint(ReadySprintArgs),
169
170    /// Enforce merged-PR gate, sync sprint status=done, then post accepted comment.
171    AcceptSprint(AcceptSprintArgs),
172
173    /// Print the full repeated command flow for a plan (1 plan = 1 issue).
174    MultiSprintGuide(MultiSprintGuideArgs),
175
176    /// Export shell completion script.
177    Completion(CompletionArgs),
178}
179
180impl Command {
181    pub fn command_id(&self) -> &'static str {
182        match self {
183            Self::BuildTaskSpec(_) => "build-task-spec",
184            Self::BuildPlanTaskSpec(_) => "build-plan-task-spec",
185            Self::StartPlan(_) => "start-plan",
186            Self::StatusPlan(_) => "status-plan",
187            Self::LinkPr(_) => "link-pr",
188            Self::ReadyPlan(_) => "ready-plan",
189            Self::ClosePlan(_) => "close-plan",
190            Self::CleanupWorktrees(_) => "cleanup-worktrees",
191            Self::StartSprint(_) => "start-sprint",
192            Self::ReadySprint(_) => "ready-sprint",
193            Self::AcceptSprint(_) => "accept-sprint",
194            Self::MultiSprintGuide(_) => "multi-sprint-guide",
195            Self::Completion(_) => "completion",
196        }
197    }
198
199    pub fn schema_version(&self) -> String {
200        format!("plan-issue-cli.{}.v1", self.command_id().replace('-', "."))
201    }
202
203    pub fn payload(&self) -> Value {
204        let payload = match self {
205            Self::BuildTaskSpec(args) => serde_json::to_value(args),
206            Self::BuildPlanTaskSpec(args) => serde_json::to_value(args),
207            Self::StartPlan(args) => serde_json::to_value(args),
208            Self::StatusPlan(args) => serde_json::to_value(args),
209            Self::LinkPr(args) => serde_json::to_value(args),
210            Self::ReadyPlan(args) => serde_json::to_value(args),
211            Self::ClosePlan(args) => serde_json::to_value(args),
212            Self::CleanupWorktrees(args) => serde_json::to_value(args),
213            Self::StartSprint(args) => serde_json::to_value(args),
214            Self::ReadySprint(args) => serde_json::to_value(args),
215            Self::AcceptSprint(args) => serde_json::to_value(args),
216            Self::MultiSprintGuide(args) => serde_json::to_value(args),
217            Self::Completion(args) => serde_json::to_value(args),
218        };
219
220        payload.unwrap_or(Value::Null)
221    }
222
223    pub fn validate(&self, dry_run: bool) -> Result<(), ValidationError> {
224        match self {
225            Self::BuildTaskSpec(args) => validate_grouping(&args.grouping),
226            Self::BuildPlanTaskSpec(args) => validate_grouping(&args.grouping),
227            Self::StartPlan(args) => validate_grouping(&args.grouping),
228            Self::StartSprint(args) => validate_grouping(&args.grouping),
229            Self::ReadySprint(args) => validate_grouping(&args.grouping),
230            Self::AcceptSprint(args) => validate_grouping(&args.grouping),
231            Self::ClosePlan(args) => validate_close_plan_args(args, dry_run),
232            Self::LinkPr(args) => validate_link_pr_args(args),
233            Self::MultiSprintGuide(args) => validate_multi_sprint_guide_args(args),
234            Self::Completion(_)
235            | Self::StatusPlan(_)
236            | Self::ReadyPlan(_)
237            | Self::CleanupWorktrees(_) => Ok(()),
238        }
239    }
240}
241
242fn validate_grouping(grouping: &GroupingArgs) -> Result<(), ValidationError> {
243    match grouping.strategy {
244        SplitStrategy::Deterministic => {
245            let Some(pr_grouping) = grouping.pr_grouping else {
246                return Err(ValidationError::new(
247                    "invalid-pr-grouping",
248                    "--strategy deterministic requires --pr-grouping <per-sprint|group>",
249                ));
250            };
251            if grouping.default_pr_grouping.is_some() {
252                return Err(ValidationError::new(
253                    "invalid-pr-grouping",
254                    "--default-pr-grouping is only valid when --strategy auto",
255                ));
256            }
257            match (pr_grouping, grouping.pr_group.is_empty()) {
258                (PrGrouping::PerSprint, false) => Err(ValidationError::new(
259                    "invalid-pr-grouping",
260                    "--pr-group is only valid when --pr-grouping group",
261                )),
262                (PrGrouping::Group, true) => Err(ValidationError::new(
263                    "invalid-pr-grouping",
264                    "--pr-grouping group with --strategy deterministic requires --pr-group mappings",
265                )),
266                _ => Ok(()),
267            }
268        }
269        SplitStrategy::Auto => {
270            if grouping.pr_grouping.is_some() {
271                return Err(ValidationError::new(
272                    "invalid-pr-grouping",
273                    "--pr-grouping cannot be used with --strategy auto; use sprint metadata or --default-pr-grouping",
274                ));
275            }
276            Ok(())
277        }
278    }
279}
280
281fn validate_close_plan_args(args: &ClosePlanArgs, dry_run: bool) -> Result<(), ValidationError> {
282    if args.issue.is_some() && args.body_file.is_some() {
283        return Err(ValidationError::new(
284            "conflicting-issue-source",
285            "use either --issue or --body-file for close-plan, not both",
286        ));
287    }
288
289    if dry_run && args.body_file.is_none() {
290        return Err(ValidationError::new(
291            "missing-body-file",
292            "--body-file is required for close-plan --dry-run",
293        ));
294    }
295
296    if !dry_run && args.issue.is_none() {
297        return Err(ValidationError::new(
298            "missing-issue",
299            "--issue is required for close-plan",
300        ));
301    }
302
303    if !dry_run && args.body_file.is_some() {
304        return Err(ValidationError::new(
305            "invalid-body-file-mode",
306            "--body-file is only supported with --dry-run",
307        ));
308    }
309
310    Ok(())
311}
312
313fn validate_link_pr_args(args: &LinkPrArgs) -> Result<(), ValidationError> {
314    let pr = args.pr.trim();
315    if issue_body::parse_pr_number(pr).is_none() {
316        return Err(ValidationError::new(
317            "invalid-pr-reference",
318            "--pr must be a concrete PR reference (`#123`, `123`, or GitHub pull URL)",
319        ));
320    }
321
322    if let Some(task) = args.task.as_deref()
323        && task.trim().is_empty()
324    {
325        return Err(ValidationError::new(
326            "invalid-task-id",
327            "--task cannot be empty",
328        ));
329    }
330
331    if let Some(group) = args.pr_group.as_deref()
332        && group.trim().is_empty()
333    {
334        return Err(ValidationError::new(
335            "invalid-pr-group",
336            "--pr-group cannot be empty",
337        ));
338    }
339
340    Ok(())
341}
342
343fn validate_multi_sprint_guide_args(args: &MultiSprintGuideArgs) -> Result<(), ValidationError> {
344    if let Some(to_sprint) = args.to_sprint
345        && to_sprint < args.from_sprint
346    {
347        return Err(ValidationError::new(
348            "invalid-sprint-range",
349            "--from-sprint must be <= --to-sprint",
350        ));
351    }
352    Ok(())
353}