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(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 LinkPr(LinkPrArgs),
150
151 ReadyPlan(ReadyPlanArgs),
153
154 ClosePlan(ClosePlanArgs),
156
157 CleanupWorktrees(CleanupWorktreesArgs),
159
160 StartSprint(StartSprintArgs),
162
163 ReadySprint(ReadySprintArgs),
165
166 AcceptSprint(AcceptSprintArgs),
168
169 MultiSprintGuide(MultiSprintGuideArgs),
171
172 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}