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    /// PR grouping mode.
80    #[arg(long, value_enum, value_name = "mode")]
81    pub pr_grouping: PrGrouping,
82
83    /// Split strategy for group assignment.
84    #[arg(
85        long,
86        value_enum,
87        default_value_t = SplitStrategy::Deterministic,
88        value_name = "strategy"
89    )]
90    pub strategy: SplitStrategy,
91
92    /// Explicit task->group mapping (`<task>=<group>`). Repeatable.
93    #[arg(
94        long = "pr-group",
95        value_name = "task=group",
96        value_parser = parse_pr_group_mapping
97    )]
98    pub pr_group: Vec<PrGroupMapping>,
99}
100
101#[derive(Debug, Clone, Args, Default, Serialize)]
102pub struct SummaryArgs {
103    /// Inline review summary text.
104    #[arg(long, conflicts_with = "summary_file", value_name = "text")]
105    pub summary: Option<String>,
106
107    /// Path to markdown/text review summary.
108    #[arg(long, conflicts_with = "summary", value_name = "path")]
109    pub summary_file: Option<std::path::PathBuf>,
110}
111
112#[derive(Debug, Clone, Args, Default, Serialize)]
113pub struct CommentModeArgs {
114    /// Emit comment output.
115    #[arg(long, conflicts_with = "no_comment")]
116    pub comment: bool,
117
118    /// Disable comment output.
119    #[arg(long = "no-comment", conflicts_with = "comment")]
120    pub no_comment: bool,
121}
122
123#[derive(Debug, Clone, Args, Default, Serialize)]
124pub struct CommentTextArgs {
125    /// Inline close comment.
126    #[arg(long, conflicts_with = "comment_file", value_name = "text")]
127    pub comment: Option<String>,
128
129    /// Path to close comment markdown/text.
130    #[arg(long, conflicts_with = "comment", value_name = "path")]
131    pub comment_file: Option<std::path::PathBuf>,
132}
133
134#[derive(Debug, Clone, Subcommand)]
135pub enum Command {
136    /// Build sprint-scoped task-spec TSV from a plan.
137    BuildTaskSpec(BuildTaskSpecArgs),
138
139    /// Build plan-scoped task-spec TSV (all sprints) for the single plan issue.
140    BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
141
142    /// Open one plan issue with all plan tasks in Task Decomposition.
143    StartPlan(StartPlanArgs),
144
145    /// Wrapper of issue-delivery-loop status for the plan issue.
146    StatusPlan(StatusPlanArgs),
147
148    /// Link PR to task rows and set runtime status (default: in-progress).
149    LinkPr(LinkPrArgs),
150
151    /// Wrapper of issue-delivery-loop ready-for-review for final plan review.
152    ReadyPlan(ReadyPlanArgs),
153
154    /// Close the single plan issue after final approval + merged PR gates, then enforce worktree cleanup.
155    ClosePlan(ClosePlanArgs),
156
157    /// Enforce cleanup of all issue-assigned task worktrees.
158    CleanupWorktrees(CleanupWorktreesArgs),
159
160    /// Start sprint from Task Decomposition runtime truth after previous sprint merge+done gate passes.
161    StartSprint(StartSprintArgs),
162
163    /// Post sprint-ready comment for main-agent review before merge.
164    ReadySprint(ReadySprintArgs),
165
166    /// Enforce merged-PR gate, sync sprint status=done, then post accepted comment.
167    AcceptSprint(AcceptSprintArgs),
168
169    /// Print the full repeated command flow for a plan (1 plan = 1 issue).
170    MultiSprintGuide(MultiSprintGuideArgs),
171
172    /// Export shell completion script.
173    Completion(CompletionArgs),
174}
175
176impl Command {
177    pub fn command_id(&self) -> &'static str {
178        match self {
179            Self::BuildTaskSpec(_) => "build-task-spec",
180            Self::BuildPlanTaskSpec(_) => "build-plan-task-spec",
181            Self::StartPlan(_) => "start-plan",
182            Self::StatusPlan(_) => "status-plan",
183            Self::LinkPr(_) => "link-pr",
184            Self::ReadyPlan(_) => "ready-plan",
185            Self::ClosePlan(_) => "close-plan",
186            Self::CleanupWorktrees(_) => "cleanup-worktrees",
187            Self::StartSprint(_) => "start-sprint",
188            Self::ReadySprint(_) => "ready-sprint",
189            Self::AcceptSprint(_) => "accept-sprint",
190            Self::MultiSprintGuide(_) => "multi-sprint-guide",
191            Self::Completion(_) => "completion",
192        }
193    }
194
195    pub fn schema_version(&self) -> String {
196        format!("plan-issue-cli.{}.v1", self.command_id().replace('-', "."))
197    }
198
199    pub fn payload(&self) -> Value {
200        let payload = match self {
201            Self::BuildTaskSpec(args) => serde_json::to_value(args),
202            Self::BuildPlanTaskSpec(args) => serde_json::to_value(args),
203            Self::StartPlan(args) => serde_json::to_value(args),
204            Self::StatusPlan(args) => serde_json::to_value(args),
205            Self::LinkPr(args) => serde_json::to_value(args),
206            Self::ReadyPlan(args) => serde_json::to_value(args),
207            Self::ClosePlan(args) => serde_json::to_value(args),
208            Self::CleanupWorktrees(args) => serde_json::to_value(args),
209            Self::StartSprint(args) => serde_json::to_value(args),
210            Self::ReadySprint(args) => serde_json::to_value(args),
211            Self::AcceptSprint(args) => serde_json::to_value(args),
212            Self::MultiSprintGuide(args) => serde_json::to_value(args),
213            Self::Completion(args) => serde_json::to_value(args),
214        };
215
216        payload.unwrap_or(Value::Null)
217    }
218
219    pub fn validate(&self, dry_run: bool) -> Result<(), ValidationError> {
220        match self {
221            Self::BuildTaskSpec(args) => validate_grouping(&args.grouping),
222            Self::BuildPlanTaskSpec(args) => validate_grouping(&args.grouping),
223            Self::StartPlan(args) => validate_grouping(&args.grouping),
224            Self::StartSprint(args) => validate_grouping(&args.grouping),
225            Self::ReadySprint(args) => validate_grouping(&args.grouping),
226            Self::AcceptSprint(args) => validate_grouping(&args.grouping),
227            Self::ClosePlan(args) => validate_close_plan_args(args, dry_run),
228            Self::LinkPr(args) => validate_link_pr_args(args),
229            Self::MultiSprintGuide(args) => validate_multi_sprint_guide_args(args),
230            Self::Completion(_)
231            | Self::StatusPlan(_)
232            | Self::ReadyPlan(_)
233            | Self::CleanupWorktrees(_) => Ok(()),
234        }
235    }
236}
237
238fn validate_grouping(grouping: &GroupingArgs) -> Result<(), ValidationError> {
239    match (
240        grouping.pr_grouping,
241        grouping.strategy,
242        grouping.pr_group.is_empty(),
243    ) {
244        (PrGrouping::PerSprint, _, false) => Err(ValidationError::new(
245            "invalid-pr-grouping",
246            "--pr-group is only valid when --pr-grouping group",
247        )),
248        (PrGrouping::Group, SplitStrategy::Deterministic, true) => Err(ValidationError::new(
249            "invalid-pr-grouping",
250            "--pr-grouping group with --strategy deterministic requires --pr-group mappings",
251        )),
252        _ => Ok(()),
253    }
254}
255
256fn validate_close_plan_args(args: &ClosePlanArgs, dry_run: bool) -> Result<(), ValidationError> {
257    if args.issue.is_some() && args.body_file.is_some() {
258        return Err(ValidationError::new(
259            "conflicting-issue-source",
260            "use either --issue or --body-file for close-plan, not both",
261        ));
262    }
263
264    if dry_run && args.body_file.is_none() {
265        return Err(ValidationError::new(
266            "missing-body-file",
267            "--body-file is required for close-plan --dry-run",
268        ));
269    }
270
271    if !dry_run && args.issue.is_none() {
272        return Err(ValidationError::new(
273            "missing-issue",
274            "--issue is required for close-plan",
275        ));
276    }
277
278    if !dry_run && args.body_file.is_some() {
279        return Err(ValidationError::new(
280            "invalid-body-file-mode",
281            "--body-file is only supported with --dry-run",
282        ));
283    }
284
285    Ok(())
286}
287
288fn validate_link_pr_args(args: &LinkPrArgs) -> Result<(), ValidationError> {
289    let pr = args.pr.trim();
290    if issue_body::parse_pr_number(pr).is_none() {
291        return Err(ValidationError::new(
292            "invalid-pr-reference",
293            "--pr must be a concrete PR reference (`#123`, `123`, or GitHub pull URL)",
294        ));
295    }
296
297    if let Some(task) = args.task.as_deref()
298        && task.trim().is_empty()
299    {
300        return Err(ValidationError::new(
301            "invalid-task-id",
302            "--task cannot be empty",
303        ));
304    }
305
306    if let Some(group) = args.pr_group.as_deref()
307        && group.trim().is_empty()
308    {
309        return Err(ValidationError::new(
310            "invalid-pr-group",
311            "--pr-group cannot be empty",
312        ));
313    }
314
315    Ok(())
316}
317
318fn validate_multi_sprint_guide_args(args: &MultiSprintGuideArgs) -> Result<(), ValidationError> {
319    if let Some(to_sprint) = args.to_sprint
320        && to_sprint < args.from_sprint
321    {
322        return Err(ValidationError::new(
323            "invalid-sprint-range",
324            "--from-sprint must be <= --to-sprint",
325        ));
326    }
327    Ok(())
328}