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 #[arg(long, default_value = "subagent", value_name = "text")]
66 pub owner_prefix: String,
67
68 #[arg(long, default_value = "issue", value_name = "text")]
70 pub branch_prefix: String,
71
72 #[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 #[arg(long, value_enum, value_name = "mode")]
81 pub pr_grouping: PrGrouping,
82
83 #[arg(
85 long,
86 value_enum,
87 default_value_t = SplitStrategy::Deterministic,
88 value_name = "strategy"
89 )]
90 pub strategy: SplitStrategy,
91
92 #[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 #[arg(long, conflicts_with = "summary_file", value_name = "text")]
105 pub summary: Option<String>,
106
107 #[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 #[arg(long, conflicts_with = "no_comment")]
116 pub comment: bool,
117
118 #[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 #[arg(long, conflicts_with = "comment_file", value_name = "text")]
127 pub comment: Option<String>,
128
129 #[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 BuildTaskSpec(BuildTaskSpecArgs),
138
139 BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
141
142 StartPlan(StartPlanArgs),
144
145 StatusPlan(StatusPlanArgs),
147
148 ReadyPlan(ReadyPlanArgs),
150
151 ClosePlan(ClosePlanArgs),
153
154 CleanupWorktrees(CleanupWorktreesArgs),
156
157 StartSprint(StartSprintArgs),
159
160 ReadySprint(ReadySprintArgs),
162
163 AcceptSprint(AcceptSprintArgs),
165
166 MultiSprintGuide(MultiSprintGuideArgs),
168
169 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}