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 #[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(
81 long,
82 value_enum,
83 default_value_t = SplitStrategy::Deterministic,
84 value_name = "strategy"
85 )]
86 pub strategy: SplitStrategy,
87
88 #[arg(long, value_enum, value_name = "mode")]
90 pub pr_grouping: Option<PrGrouping>,
91
92 #[arg(long = "default-pr-grouping", value_enum, value_name = "mode")]
94 pub default_pr_grouping: Option<PrGrouping>,
95
96 #[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 #[arg(long, conflicts_with = "summary_file", value_name = "text")]
109 pub summary: Option<String>,
110
111 #[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 #[arg(long, conflicts_with = "no_comment")]
120 pub comment: bool,
121
122 #[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 #[arg(long, conflicts_with = "comment_file", value_name = "text")]
131 pub comment: Option<String>,
132
133 #[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 BuildTaskSpec(BuildTaskSpecArgs),
142
143 BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
145
146 StartPlan(StartPlanArgs),
148
149 StatusPlan(StatusPlanArgs),
151
152 LinkPr(LinkPrArgs),
154
155 ReadyPlan(ReadyPlanArgs),
157
158 ClosePlan(ClosePlanArgs),
160
161 CleanupWorktrees(CleanupWorktreesArgs),
163
164 StartSprint(StartSprintArgs),
166
167 ReadySprint(ReadySprintArgs),
169
170 AcceptSprint(AcceptSprintArgs),
172
173 MultiSprintGuide(MultiSprintGuideArgs),
175
176 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}