1pub mod build;
2pub mod completion;
3pub mod plan;
4pub mod record;
5pub mod sprint;
6pub mod tracking;
7
8use clap::{Args, Subcommand, ValueEnum};
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::{ValidationError, issue_body};
13
14use self::build::{BuildPlanTaskSpecArgs, BuildTaskSpecArgs};
15use self::completion::CompletionArgs;
16use self::plan::{
17 CleanupWorktreesArgs, ClosePlanArgs, LinkPrArgs, ReadyPlanArgs, ResolveApprovalArgs,
18 StartPlanArgs, StatusPlanArgs,
19};
20use self::record::RecordArgs;
21use self::sprint::{AcceptSprintArgs, MultiSprintGuideArgs, ReadySprintArgs, StartSprintArgs};
22use self::tracking::TrackingArgs;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
25pub enum PrGrouping {
26 #[value(name = "per-sprint", alias = "per-spring")]
27 PerSprint,
28 #[value(name = "group")]
29 Group,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
33pub enum SplitStrategy {
34 #[value(name = "deterministic")]
35 Deterministic,
36 #[value(name = "auto")]
37 Auto,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct PrGroupMapping {
42 pub task: String,
43 pub group: String,
44}
45
46fn parse_pr_group_mapping(raw: &str) -> Result<PrGroupMapping, String> {
47 let (task_raw, group_raw) = raw
48 .split_once('=')
49 .ok_or_else(|| "expected format <task>=<group>".to_string())?;
50
51 let task = task_raw.trim();
52 let group = group_raw.trim();
53
54 if task.is_empty() {
55 return Err("task key in --pr-group cannot be empty".to_string());
56 }
57 if group.is_empty() {
58 return Err("group name in --pr-group cannot be empty".to_string());
59 }
60
61 Ok(PrGroupMapping {
62 task: task.to_string(),
63 group: group.to_string(),
64 })
65}
66
67#[derive(Debug, Clone, Args, Serialize)]
68pub struct PrefixArgs {
69 #[arg(long, default_value = "subagent", value_name = "text")]
71 pub owner_prefix: String,
72
73 #[arg(long, default_value = "feat", value_name = "text")]
77 pub branch_prefix: String,
78
79 #[arg(long, default_value = "feat__", value_name = "text")]
81 pub worktree_prefix: String,
82}
83
84#[derive(Debug, Clone, Args, Serialize)]
85pub struct GroupingArgs {
86 #[arg(
88 long,
89 value_enum,
90 default_value_t = SplitStrategy::Deterministic,
91 value_name = "strategy"
92 )]
93 pub strategy: SplitStrategy,
94
95 #[arg(long, value_enum, value_name = "mode")]
97 pub pr_grouping: Option<PrGrouping>,
98
99 #[arg(long = "default-pr-grouping", value_enum, value_name = "mode")]
101 pub default_pr_grouping: Option<PrGrouping>,
102
103 #[arg(
105 long = "pr-group",
106 value_name = "task=group",
107 value_parser = parse_pr_group_mapping
108 )]
109 pub pr_group: Vec<PrGroupMapping>,
110}
111
112#[derive(Debug, Clone, Args, Default, Serialize)]
113pub struct SummaryArgs {
114 #[arg(long, conflicts_with = "summary_file", value_name = "text")]
116 pub summary: Option<String>,
117
118 #[arg(long, conflicts_with = "summary", value_name = "path")]
120 pub summary_file: Option<std::path::PathBuf>,
121}
122
123#[derive(Debug, Clone, Args, Default, Serialize)]
124pub struct CommentModeArgs {
125 #[arg(long, conflicts_with = "no_comment")]
127 pub comment: bool,
128
129 #[arg(long = "no-comment", conflicts_with = "comment")]
131 pub no_comment: bool,
132}
133
134#[derive(Debug, Clone, Args, Default, Serialize)]
135pub struct CommentTextArgs {
136 #[arg(long, conflicts_with = "comment_file", value_name = "text")]
138 pub comment: Option<String>,
139
140 #[arg(long, conflicts_with = "comment", value_name = "path")]
142 pub comment_file: Option<std::path::PathBuf>,
143}
144
145#[derive(Debug, Clone, Subcommand)]
146pub enum Command {
147 BuildTaskSpec(BuildTaskSpecArgs),
149
150 BuildPlanTaskSpec(BuildPlanTaskSpecArgs),
152
153 StartPlan(StartPlanArgs),
155
156 StatusPlan(StatusPlanArgs),
158
159 LinkPr(LinkPrArgs),
161
162 ReadyPlan(ReadyPlanArgs),
164
165 ClosePlan(ClosePlanArgs),
167
168 CleanupWorktrees(CleanupWorktreesArgs),
170
171 StartSprint(StartSprintArgs),
173
174 ReadySprint(ReadySprintArgs),
176
177 AcceptSprint(AcceptSprintArgs),
179
180 MultiSprintGuide(MultiSprintGuideArgs),
182
183 ResolveApproval(ResolveApprovalArgs),
186
187 Record(RecordArgs),
189
190 Tracking(TrackingArgs),
193
194 Completion(CompletionArgs),
196}
197
198impl Command {
199 pub fn command_id(&self) -> &'static str {
200 match self {
201 Self::BuildTaskSpec(_) => "build-task-spec",
202 Self::BuildPlanTaskSpec(_) => "build-plan-task-spec",
203 Self::StartPlan(_) => "start-plan",
204 Self::StatusPlan(_) => "status-plan",
205 Self::LinkPr(_) => "link-pr",
206 Self::ReadyPlan(_) => "ready-plan",
207 Self::ClosePlan(_) => "close-plan",
208 Self::CleanupWorktrees(_) => "cleanup-worktrees",
209 Self::StartSprint(_) => "start-sprint",
210 Self::ReadySprint(_) => "ready-sprint",
211 Self::AcceptSprint(_) => "accept-sprint",
212 Self::MultiSprintGuide(_) => "multi-sprint-guide",
213 Self::ResolveApproval(_) => "resolve-approval",
214 Self::Record(args) => args.command_id(),
215 Self::Tracking(args) => args.command_id(),
216 Self::Completion(_) => "completion",
217 }
218 }
219
220 pub fn schema_version(&self) -> String {
221 let suffix = match self {
228 Self::StartPlan(_) => "v2",
230 Self::StatusPlan(_) => "v2",
232 Self::StartSprint(_) => "v2",
236 Self::Record(_) => "v2",
242 Self::Tracking(_) => "v1",
243 _ => "v1",
244 };
245 format!(
246 "plan-issue.{}.{suffix}",
247 self.command_id().replace('-', ".")
248 )
249 }
250
251 pub fn payload(&self) -> Value {
252 let payload = match self {
253 Self::BuildTaskSpec(args) => serde_json::to_value(args),
254 Self::BuildPlanTaskSpec(args) => serde_json::to_value(args),
255 Self::StartPlan(args) => serde_json::to_value(args),
256 Self::StatusPlan(args) => serde_json::to_value(args),
257 Self::LinkPr(args) => serde_json::to_value(args),
258 Self::ReadyPlan(args) => serde_json::to_value(args),
259 Self::ClosePlan(args) => serde_json::to_value(args),
260 Self::CleanupWorktrees(args) => serde_json::to_value(args),
261 Self::StartSprint(args) => serde_json::to_value(args),
262 Self::ReadySprint(args) => serde_json::to_value(args),
263 Self::AcceptSprint(args) => serde_json::to_value(args),
264 Self::MultiSprintGuide(args) => serde_json::to_value(args),
265 Self::ResolveApproval(args) => serde_json::to_value(args),
266 Self::Record(args) => serde_json::to_value(args),
267 Self::Tracking(args) => serde_json::to_value(args),
268 Self::Completion(args) => serde_json::to_value(args),
269 };
270
271 payload.unwrap_or(Value::Null)
272 }
273
274 pub fn validate(&self, dry_run: bool) -> Result<(), ValidationError> {
275 match self {
276 Self::BuildTaskSpec(args) => validate_grouping(&args.grouping),
277 Self::BuildPlanTaskSpec(args) => validate_grouping(&args.grouping),
278 Self::StartPlan(args) => validate_grouping(&args.grouping),
279 Self::StartSprint(args) => validate_grouping_with_plan_inference(&args.grouping),
285 Self::ReadySprint(args) => validate_grouping_with_plan_inference(&args.grouping),
286 Self::AcceptSprint(args) => validate_grouping_with_plan_inference(&args.grouping),
287 Self::ClosePlan(args) => validate_close_plan_args(args, dry_run),
288 Self::LinkPr(args) => validate_link_pr_args(args),
289 Self::MultiSprintGuide(args) => validate_multi_sprint_guide_args(args),
290 Self::Record(_) => Ok(()),
291 Self::Tracking(_) => Ok(()),
292 Self::Completion(_)
293 | Self::StatusPlan(_)
294 | Self::ReadyPlan(_)
295 | Self::ResolveApproval(_)
296 | Self::CleanupWorktrees(_) => Ok(()),
297 }
298 }
299}
300
301impl RecordArgs {
302 pub fn command_id(&self) -> &'static str {
303 match &self.command {
304 record::RecordCommand::Open(_) => "record.open",
305 record::RecordCommand::Attach(_) => "record.attach",
306 record::RecordCommand::Post(_) => "record.post",
307 record::RecordCommand::RepairDashboard(_) => "record.repair-dashboard",
308 record::RecordCommand::Close(_) => "record.close",
309 record::RecordCommand::Audit(_) => "record.audit",
310 record::RecordCommand::Template(_) => "record.template",
311 record::RecordCommand::Restore(_) => "record.restore",
312 }
313 }
314}
315
316impl TrackingArgs {
317 pub fn command_id(&self) -> &'static str {
318 match &self.command {
319 tracking::TrackingCommand::Status(_) => "tracking.status",
320 tracking::TrackingCommand::Run(run) => match &run.command {
321 tracking::TrackingRunCommand::Init(_) => "tracking.run.init",
322 tracking::TrackingRunCommand::Update(_) => "tracking.run.update",
323 },
324 tracking::TrackingCommand::Checkpoint(_) => "tracking.checkpoint",
325 tracking::TrackingCommand::CloseReady(_) => "tracking.close-ready",
326 }
327 }
328}
329
330fn validate_grouping(grouping: &GroupingArgs) -> Result<(), ValidationError> {
331 match grouping.strategy {
332 SplitStrategy::Deterministic => {
333 let Some(pr_grouping) = grouping.pr_grouping else {
334 return Err(ValidationError::new(
335 "invalid-pr-grouping",
336 "--strategy deterministic requires --pr-grouping <per-sprint|group>",
337 ));
338 };
339 if grouping.default_pr_grouping.is_some() {
340 return Err(ValidationError::new(
341 "invalid-pr-grouping",
342 "--default-pr-grouping is only valid when --strategy auto",
343 ));
344 }
345 match (pr_grouping, grouping.pr_group.is_empty()) {
346 (PrGrouping::PerSprint, false) => Err(ValidationError::new(
347 "invalid-pr-grouping",
348 "--pr-group is only valid when --pr-grouping group",
349 )),
350 (PrGrouping::Group, true) => Err(ValidationError::new(
351 "invalid-pr-grouping",
352 "--pr-grouping group with --strategy deterministic requires --pr-group mappings",
353 )),
354 _ => Ok(()),
355 }
356 }
357 SplitStrategy::Auto => {
358 if grouping.pr_grouping.is_some() {
359 return Err(ValidationError::new(
360 "invalid-pr-grouping",
361 "--pr-grouping cannot be used with --strategy auto; use sprint metadata or --default-pr-grouping",
362 ));
363 }
364 Ok(())
365 }
366 }
367}
368
369fn validate_grouping_with_plan_inference(grouping: &GroupingArgs) -> Result<(), ValidationError> {
375 if grouping.strategy == SplitStrategy::Deterministic
377 && grouping.pr_grouping.is_none()
378 && grouping.default_pr_grouping.is_none()
379 && grouping.pr_group.is_empty()
380 {
381 return Ok(());
382 }
383 validate_grouping(grouping)
384}
385
386fn validate_close_plan_args(args: &ClosePlanArgs, dry_run: bool) -> Result<(), ValidationError> {
387 if args.issue.is_some() && args.body_file.is_some() {
388 return Err(ValidationError::new(
389 "conflicting-issue-source",
390 "use either --issue or --body-file for close-plan, not both",
391 ));
392 }
393
394 if dry_run && args.body_file.is_none() {
395 return Err(ValidationError::new(
396 "missing-body-file",
397 "--body-file is required for close-plan --dry-run",
398 ));
399 }
400
401 if !dry_run && args.issue.is_none() {
402 return Err(ValidationError::new(
403 "missing-issue",
404 "--issue is required for close-plan",
405 ));
406 }
407
408 if !dry_run && args.body_file.is_some() {
409 return Err(ValidationError::new(
410 "invalid-body-file-mode",
411 "--body-file is only supported with --dry-run",
412 ));
413 }
414
415 Ok(())
416}
417
418fn validate_link_pr_args(args: &LinkPrArgs) -> Result<(), ValidationError> {
419 let pr = args.pr.trim();
420 if issue_body::parse_pr_number(pr).is_none() {
421 return Err(ValidationError::new(
422 "invalid-pr-reference",
423 "--pr must be a concrete PR reference (`#123`, `123`, or GitHub pull URL)",
424 ));
425 }
426
427 if let Some(task) = args.task.as_deref()
428 && task.trim().is_empty()
429 {
430 return Err(ValidationError::new(
431 "invalid-task-id",
432 "--task cannot be empty",
433 ));
434 }
435
436 if let Some(group) = args.pr_group.as_deref()
437 && group.trim().is_empty()
438 {
439 return Err(ValidationError::new(
440 "invalid-pr-group",
441 "--pr-group cannot be empty",
442 ));
443 }
444
445 Ok(())
446}
447
448fn validate_multi_sprint_guide_args(args: &MultiSprintGuideArgs) -> Result<(), ValidationError> {
449 if let Some(to_sprint) = args.to_sprint
450 && to_sprint < args.from_sprint
451 {
452 return Err(ValidationError::new(
453 "invalid-sprint-range",
454 "--from-sprint must be <= --to-sprint",
455 ));
456 }
457 Ok(())
458}