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;
11
12use self::build::{BuildPlanTaskSpecArgs, BuildTaskSpecArgs};
13use self::completion::CompletionArgs;
14use self::plan::{
15    CleanupWorktreesArgs, ClosePlanArgs, 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    /// Wrapper of issue-delivery-loop ready-for-review for final plan review.
149    ReadyPlan(ReadyPlanArgs),
150
151    /// Close the single plan issue after final approval + merged PR gates, then enforce worktree cleanup.
152    ClosePlan(ClosePlanArgs),
153
154    /// Enforce cleanup of all issue-assigned task worktrees.
155    CleanupWorktrees(CleanupWorktreesArgs),
156
157    /// Start sprint only after previous sprint merge+done gate passes.
158    StartSprint(StartSprintArgs),
159
160    /// Post sprint-ready comment for main-agent review before merge.
161    ReadySprint(ReadySprintArgs),
162
163    /// Enforce merged-PR gate, sync sprint status=done, then post accepted comment.
164    AcceptSprint(AcceptSprintArgs),
165
166    /// Print the full repeated command flow for a plan (1 plan = 1 issue).
167    MultiSprintGuide(MultiSprintGuideArgs),
168
169    /// Export shell completion script.
170    Completion(CompletionArgs),
171}
172
173impl Command {
174    pub fn command_id(&self) -> &'static str {
175        match self {
176            Self::BuildTaskSpec(_) => "build-task-spec",
177            Self::BuildPlanTaskSpec(_) => "build-plan-task-spec",
178            Self::StartPlan(_) => "start-plan",
179            Self::StatusPlan(_) => "status-plan",
180            Self::ReadyPlan(_) => "ready-plan",
181            Self::ClosePlan(_) => "close-plan",
182            Self::CleanupWorktrees(_) => "cleanup-worktrees",
183            Self::StartSprint(_) => "start-sprint",
184            Self::ReadySprint(_) => "ready-sprint",
185            Self::AcceptSprint(_) => "accept-sprint",
186            Self::MultiSprintGuide(_) => "multi-sprint-guide",
187            Self::Completion(_) => "completion",
188        }
189    }
190
191    pub fn schema_version(&self) -> String {
192        format!("plan-issue-cli.{}.v1", self.command_id().replace('-', "."))
193    }
194
195    pub fn payload(&self) -> Value {
196        let payload = match self {
197            Self::BuildTaskSpec(args) => serde_json::to_value(args),
198            Self::BuildPlanTaskSpec(args) => serde_json::to_value(args),
199            Self::StartPlan(args) => serde_json::to_value(args),
200            Self::StatusPlan(args) => serde_json::to_value(args),
201            Self::ReadyPlan(args) => serde_json::to_value(args),
202            Self::ClosePlan(args) => serde_json::to_value(args),
203            Self::CleanupWorktrees(args) => serde_json::to_value(args),
204            Self::StartSprint(args) => serde_json::to_value(args),
205            Self::ReadySprint(args) => serde_json::to_value(args),
206            Self::AcceptSprint(args) => serde_json::to_value(args),
207            Self::MultiSprintGuide(args) => serde_json::to_value(args),
208            Self::Completion(args) => serde_json::to_value(args),
209        };
210
211        payload.unwrap_or(Value::Null)
212    }
213
214    pub fn validate(&self, dry_run: bool) -> Result<(), ValidationError> {
215        match self {
216            Self::BuildTaskSpec(args) => validate_grouping(&args.grouping),
217            Self::BuildPlanTaskSpec(args) => validate_grouping(&args.grouping),
218            Self::StartPlan(args) => validate_grouping(&args.grouping),
219            Self::StartSprint(args) => validate_grouping(&args.grouping),
220            Self::ReadySprint(args) => validate_grouping(&args.grouping),
221            Self::AcceptSprint(args) => validate_grouping(&args.grouping),
222            Self::ClosePlan(args) => validate_close_plan_args(args, dry_run),
223            Self::MultiSprintGuide(args) => validate_multi_sprint_guide_args(args),
224            Self::Completion(_)
225            | Self::StatusPlan(_)
226            | Self::ReadyPlan(_)
227            | Self::CleanupWorktrees(_) => Ok(()),
228        }
229    }
230}
231
232fn validate_grouping(grouping: &GroupingArgs) -> Result<(), ValidationError> {
233    match (
234        grouping.pr_grouping,
235        grouping.strategy,
236        grouping.pr_group.is_empty(),
237    ) {
238        (PrGrouping::PerSprint, _, false) => Err(ValidationError::new(
239            "invalid-pr-grouping",
240            "--pr-group is only valid when --pr-grouping group",
241        )),
242        (PrGrouping::Group, SplitStrategy::Deterministic, true) => Err(ValidationError::new(
243            "invalid-pr-grouping",
244            "--pr-grouping group with --strategy deterministic requires --pr-group mappings",
245        )),
246        _ => Ok(()),
247    }
248}
249
250fn validate_close_plan_args(args: &ClosePlanArgs, dry_run: bool) -> Result<(), ValidationError> {
251    if args.issue.is_some() && args.body_file.is_some() {
252        return Err(ValidationError::new(
253            "conflicting-issue-source",
254            "use either --issue or --body-file for close-plan, not both",
255        ));
256    }
257
258    if dry_run && args.body_file.is_none() {
259        return Err(ValidationError::new(
260            "missing-body-file",
261            "--body-file is required for close-plan --dry-run",
262        ));
263    }
264
265    if !dry_run && args.issue.is_none() {
266        return Err(ValidationError::new(
267            "missing-issue",
268            "--issue is required for close-plan",
269        ));
270    }
271
272    if !dry_run && args.body_file.is_some() {
273        return Err(ValidationError::new(
274            "invalid-body-file-mode",
275            "--body-file is only supported with --dry-run",
276        ));
277    }
278
279    Ok(())
280}
281
282fn validate_multi_sprint_guide_args(args: &MultiSprintGuideArgs) -> Result<(), ValidationError> {
283    if let Some(to_sprint) = args.to_sprint
284        && to_sprint < args.from_sprint
285    {
286        return Err(ValidationError::new(
287            "invalid-sprint-range",
288            "--from-sprint must be <= --to-sprint",
289        ));
290    }
291    Ok(())
292}