1use anyhow::{Context, Result, anyhow, bail};
20use clap::{Args, Subcommand};
21use regex::Regex;
22use std::collections::HashSet;
23use std::io::{self, BufRead, IsTerminal, Write};
24
25use crate::cli::load_and_validate_queues_read_only;
26use crate::cli::queue::shared::StatusArg;
27use crate::config::Resolved;
28use crate::contracts::{QueueFile, Task, TaskStatus};
29use crate::git::{
30 GITHUB_ISSUE_SYNC_HASH_KEY, check_gh_available, compute_issue_sync_hash, create_issue,
31 edit_issue, normalize_issue_metadata_list, parse_issue_number,
32};
33
34const DEFAULT_PUBLISH_STATUSES: &[TaskStatus] = &[
35 TaskStatus::Todo,
36 TaskStatus::Doing,
37 TaskStatus::Done,
38 TaskStatus::Rejected,
39];
40const GITHUB_ISSUE_URL_KEY: &str = "github_issue_url";
41const GITHUB_ISSUE_NUMBER_KEY: &str = "github_issue_number";
42
43#[derive(Args)]
44pub struct QueueIssueArgs {
45 #[command(subcommand)]
46 pub command: QueueIssueCommand,
47}
48
49#[derive(Subcommand)]
50pub enum QueueIssueCommand {
51 Publish(QueueIssuePublishArgs),
53
54 PublishMany(QueueIssuePublishManyArgs),
56}
57
58#[derive(Args, Clone)]
60#[command(after_long_help = "Examples:\n\
61 # Preview rendered markdown for a task\n\
62 ralph queue issue publish RQ-0655 --dry-run\n\
63 # Create/update issue metadata and persist custom_fields\n\
64 ralph queue issue publish RQ-0655\n\
65 # Add labels/assignees\n\
66 ralph queue issue publish RQ-0655 --label bug --assignee @me\n\
67 # Target another repo\n\
68 ralph queue issue publish RQ-0655 --repo owner/repo")]
69pub struct QueueIssuePublishArgs {
70 pub task_id: String,
72
73 #[arg(long)]
75 pub dry_run: bool,
76
77 #[arg(long)]
79 pub label: Vec<String>,
80
81 #[arg(long)]
83 pub assignee: Vec<String>,
84
85 #[arg(long)]
87 pub repo: Option<String>,
88}
89
90#[derive(Args, Clone)]
92#[command(after_long_help = "Examples:\n\
93 # Safe preview from todo backlog\n\
94 ralph queue issue publish-many --status todo --tag bug --dry-run\n\
95 # Publish selected slice with regex and labels\n\
96 ralph queue issue publish-many --status todo --tag bug --id-pattern '^RQ-08' --label triage\n\
97 # Execute publish with confirmation override\n\
98 ralph queue issue publish-many --status todo --execute --force")]
99pub struct QueueIssuePublishManyArgs {
100 #[arg(long)]
102 pub status: Vec<StatusArg>,
103
104 #[arg(long)]
106 pub tag: Vec<String>,
107
108 #[arg(long)]
110 pub id_pattern: Option<String>,
111
112 #[arg(long)]
114 pub dry_run: bool,
115
116 #[arg(long)]
118 pub execute: bool,
119
120 #[arg(long)]
122 pub label: Vec<String>,
123
124 #[arg(long)]
126 pub assignee: Vec<String>,
127
128 #[arg(long)]
130 pub repo: Option<String>,
131}
132
133pub(crate) fn handle(resolved: &Resolved, force: bool, args: QueueIssueArgs) -> Result<()> {
134 match args.command {
135 QueueIssueCommand::Publish(args) => handle_publish(resolved, force, args),
136 QueueIssueCommand::PublishMany(args) => handle_publish_many(resolved, force, args),
137 }
138}
139
140pub(crate) fn handle_publish(
141 resolved: &Resolved,
142 force: bool,
143 args: QueueIssuePublishArgs,
144) -> Result<()> {
145 let task_id = args.task_id.trim();
146 if task_id.is_empty() {
147 bail!("Task ID must be non-empty");
148 }
149
150 if args.dry_run {
151 let (mut queue_file, _done_file) = load_and_validate_queues_read_only(resolved, false)?;
152 let result = publish_task(
153 resolved,
154 &mut queue_file,
155 task_id,
156 PublishMode::DryRun,
157 &args.label,
158 &args.assignee,
159 args.repo.as_deref(),
160 )?;
161
162 return print_single_publish_result(
163 &queue_file,
164 task_id,
165 result,
166 &args.label,
167 &args.assignee,
168 args.repo.as_deref(),
169 );
170 }
171
172 check_gh_available()?;
173 let _lock =
174 crate::queue::acquire_queue_lock(&resolved.repo_root, "queue issue publish", force)?;
175
176 crate::undo::create_undo_snapshot(resolved, &format!("queue issue publish {}", task_id))?;
178
179 let (mut queue_file, _done_file) = crate::queue::load_and_validate_queues(resolved, false)?;
180 let result = publish_task(
181 resolved,
182 &mut queue_file,
183 task_id,
184 PublishMode::Execute,
185 &args.label,
186 &args.assignee,
187 args.repo.as_deref(),
188 )?;
189
190 match &result {
191 PublishItemResult::Created | PublishItemResult::Updated => {
192 crate::queue::validate_queue(&queue_file, &resolved.id_prefix, resolved.id_width)?;
193 crate::queue::save_queue(&resolved.queue_path, &queue_file)?;
194 }
195 PublishItemResult::SkippedUnchanged => {}
196 PublishItemResult::Failed(err) => return Err(anyhow!("{err}")),
197 }
198
199 match result {
200 PublishItemResult::Created | PublishItemResult::Updated => {
201 let task = find_task(&queue_file, task_id)?;
202 let url = fetch_custom_field(&task.custom_fields, GITHUB_ISSUE_URL_KEY)
203 .unwrap_or_else(|| "unknown".to_string());
204 if matches!(result, PublishItemResult::Created) {
205 println!("Created GitHub issue: {url}");
206 } else {
207 println!("Updated GitHub issue: {url}");
208 }
209 }
210 PublishItemResult::SkippedUnchanged => {
211 println!("No changes for '{task_id}'; issue payload already synced.");
212 }
213 PublishItemResult::Failed(err) => return Err(anyhow!("{err}")),
214 }
215
216 Ok(())
217}
218
219pub(crate) fn handle_publish_many(
220 resolved: &Resolved,
221 force: bool,
222 args: QueueIssuePublishManyArgs,
223) -> Result<()> {
224 let mode = resolve_publish_mode(args.dry_run, args.execute)?;
225 let filters = parse_publish_many_filters(&args)?;
226 let (queue_for_plan, _done_file) = load_and_validate_queues_read_only(resolved, false)?;
227 let selected_task_ids = select_publishable_task_ids(&queue_for_plan, &filters)?;
228
229 if selected_task_ids.is_empty() {
230 println!("No matching tasks found for publish-many filters.");
231 return Ok(());
232 }
233
234 let mut plan_queue = queue_for_plan;
235 let mut plan_summary = PublishManySummary {
236 selected: selected_task_ids.len(),
237 ..PublishManySummary::default()
238 };
239 let mut planned = Vec::with_capacity(selected_task_ids.len());
240
241 for task_id in &selected_task_ids {
242 let result = publish_task(
243 resolved,
244 &mut plan_queue,
245 task_id,
246 PublishMode::DryRun,
247 &args.label,
248 &args.assignee,
249 args.repo.as_deref(),
250 )?;
251
252 accumulate_publish_result(&mut plan_summary, &result);
253 planned.push((task_id.clone(), result));
254 }
255
256 print_publish_many_plan(&selected_task_ids, &planned);
257 print_publish_many_summary(&plan_summary, true);
258
259 if matches!(mode, PublishMode::DryRun) {
260 return Ok(());
261 }
262
263 if !force {
264 if !is_terminal_context() {
265 bail!(
266 "Refusing to execute bulk publish in non-interactive context without --force. Use --dry-run first."
267 );
268 }
269 if !confirm_execution(&plan_summary)? {
270 bail!("Bulk publish cancelled by user");
271 }
272 }
273
274 check_gh_available()?;
275 let _lock =
276 crate::queue::acquire_queue_lock(&resolved.repo_root, "queue issue publish-many", force)?;
277
278 crate::undo::create_undo_snapshot(resolved, "queue issue publish-many")?;
280
281 let (mut queue_file, _done_file) = crate::queue::load_and_validate_queues(resolved, false)?;
282 let mut final_summary = PublishManySummary {
283 selected: selected_task_ids.len(),
284 ..PublishManySummary::default()
285 };
286 let mut failures = Vec::new();
287
288 for task_id in &selected_task_ids {
289 let result = publish_task(
290 resolved,
291 &mut queue_file,
292 task_id,
293 PublishMode::Execute,
294 &args.label,
295 &args.assignee,
296 args.repo.as_deref(),
297 )
298 .unwrap_or_else(PublishItemResult::Failed);
299
300 if let PublishItemResult::Failed(err) = &result {
301 failures.push((task_id.clone(), err.to_string()));
302 }
303
304 print_publish_many_task_result(task_id, &result);
305 accumulate_publish_result(&mut final_summary, &result);
306 }
307
308 if final_summary.has_mutations() {
309 crate::queue::validate_queue(&queue_file, &resolved.id_prefix, resolved.id_width)?;
310 crate::queue::save_queue(&resolved.queue_path, &queue_file)?;
311 }
312
313 print_publish_many_summary(&final_summary, false);
314 if !failures.is_empty() {
315 println!();
316 println!("Failures:");
317 for (task_id, reason) in failures {
318 println!(" {task_id}: {reason}");
319 }
320 bail!(
321 "publish-many completed with {} failed task(s).",
322 final_summary.failed
323 );
324 }
325
326 Ok(())
327}
328
329#[derive(Debug, Clone, Copy)]
330enum PublishMode {
331 DryRun,
332 Execute,
333}
334
335#[derive(Debug)]
336enum PublishItemResult {
337 Created,
338 Updated,
339 SkippedUnchanged,
340 Failed(anyhow::Error),
341}
342
343#[derive(Debug, Default)]
344struct PublishManySummary {
345 selected: usize,
346 created: usize,
347 updated: usize,
348 skipped: usize,
349 failed: usize,
350}
351
352impl PublishManySummary {
353 fn has_mutations(&self) -> bool {
354 self.created > 0 || self.updated > 0
355 }
356}
357
358struct PublishManyFilters {
359 statuses: Vec<TaskStatus>,
360 tags: Vec<String>,
361 id_pattern: Option<Regex>,
362}
363
364fn resolve_publish_mode(dry_run: bool, execute: bool) -> Result<PublishMode> {
365 if dry_run && execute {
366 bail!("Cannot combine --dry-run and --execute");
367 }
368 if execute {
369 Ok(PublishMode::Execute)
370 } else {
371 Ok(PublishMode::DryRun)
372 }
373}
374
375fn parse_publish_many_filters(args: &QueueIssuePublishManyArgs) -> Result<PublishManyFilters> {
376 let statuses = if args.status.is_empty() {
377 DEFAULT_PUBLISH_STATUSES.to_vec()
378 } else {
379 args.status.iter().map(|status| (*status).into()).collect()
380 };
381
382 let tags = args
383 .tag
384 .iter()
385 .map(|tag| tag.trim().to_string())
386 .filter(|tag| !tag.is_empty())
387 .collect::<Vec<_>>();
388
389 let id_pattern = match args.id_pattern.as_deref() {
390 Some(pattern) if !pattern.trim().is_empty() => {
391 Some(Regex::new(pattern).with_context(|| {
392 format!("Invalid --id-pattern '{pattern}'. Use valid regular-expression syntax.")
393 })?)
394 }
395 Some(pattern) if pattern.trim().is_empty() => {
396 bail!("--id-pattern cannot be empty when provided");
397 }
398 Some(_) => unreachable!(),
399 None => None,
400 };
401
402 Ok(PublishManyFilters {
403 statuses,
404 tags,
405 id_pattern,
406 })
407}
408
409fn select_publishable_task_ids(
410 queue_file: &QueueFile,
411 filters: &PublishManyFilters,
412) -> Result<Vec<String>> {
413 let status_filter: HashSet<TaskStatus> = filters.statuses.iter().copied().collect();
414 let statuses = status_filter.into_iter().collect::<Vec<_>>();
415
416 let tasks = crate::queue::filter_tasks(queue_file, &statuses, &filters.tags, &[], None);
417 let mut selected = Vec::new();
418
419 for task in tasks {
420 if let Some(pattern) = &filters.id_pattern
421 && !pattern.is_match(task.id.trim())
422 {
423 continue;
424 }
425 selected.push(task.id.trim().to_string());
426 }
427
428 Ok(selected)
429}
430
431fn publish_task(
432 resolved: &Resolved,
433 queue: &mut QueueFile,
434 task_id: &str,
435 mode: PublishMode,
436 labels: &[String],
437 assignees: &[String],
438 repo: Option<&str>,
439) -> Result<PublishItemResult> {
440 let normalized_labels = normalize_issue_metadata_list(labels);
441 let normalized_assignees = normalize_issue_metadata_list(assignees);
442
443 let task = find_task_mut(queue, task_id)?;
444 let title = format!("{}: {}", task.id.trim(), task.title);
445 let body = super::export::render_task_as_github_issue_body(task);
446 let sync_hash = compute_issue_sync_hash(
447 &title,
448 &body,
449 &normalized_labels,
450 &normalized_assignees,
451 repo,
452 )?;
453
454 let existing_url = fetch_custom_field(&task.custom_fields, GITHUB_ISSUE_URL_KEY);
455
456 if let Some(url) = existing_url {
457 let existing_sync_hash =
458 fetch_custom_field(&task.custom_fields, GITHUB_ISSUE_SYNC_HASH_KEY);
459 if existing_sync_hash.as_deref() == Some(sync_hash.as_str()) {
460 return Ok(PublishItemResult::SkippedUnchanged);
461 }
462
463 if matches!(mode, PublishMode::DryRun) {
464 return Ok(PublishItemResult::Updated);
465 }
466
467 let tmp = crate::fsutil::create_ralph_temp_file("issue")
468 .context("create temp file for issue body")?;
469 std::fs::write(tmp.path(), body).context("write issue body to temp file")?;
470 edit_issue(
471 &resolved.repo_root,
472 repo,
473 &url,
474 &title,
475 tmp.path(),
476 &normalized_labels,
477 &normalized_assignees,
478 )
479 .with_context(|| format!("Failed to update GitHub issue at {url}"))?;
480
481 if fetch_custom_field(&task.custom_fields, GITHUB_ISSUE_NUMBER_KEY).is_none()
482 && let Some(number) = parse_issue_number(&url)
483 {
484 task.custom_fields
485 .insert(GITHUB_ISSUE_NUMBER_KEY.to_string(), number.to_string());
486 }
487
488 task.custom_fields
489 .insert(GITHUB_ISSUE_SYNC_HASH_KEY.to_string(), sync_hash);
490 task.updated_at = Some(crate::timeutil::now_utc_rfc3339_or_fallback());
491 Ok(PublishItemResult::Updated)
492 } else {
493 if matches!(mode, PublishMode::DryRun) {
494 return Ok(PublishItemResult::Created);
495 }
496
497 let tmp = crate::fsutil::create_ralph_temp_file("issue")
498 .context("create temp file for issue body")?;
499 std::fs::write(tmp.path(), body).context("write issue body to temp file")?;
500 let issue = create_issue(
501 &resolved.repo_root,
502 repo,
503 &title,
504 tmp.path(),
505 &normalized_labels,
506 &normalized_assignees,
507 )?;
508
509 task.custom_fields
510 .insert(GITHUB_ISSUE_URL_KEY.to_string(), issue.url.clone());
511 if let Some(number) = issue.number {
512 task.custom_fields
513 .insert(GITHUB_ISSUE_NUMBER_KEY.to_string(), number.to_string());
514 }
515 task.custom_fields
516 .insert(GITHUB_ISSUE_SYNC_HASH_KEY.to_string(), sync_hash);
517 task.updated_at = Some(crate::timeutil::now_utc_rfc3339_or_fallback());
518
519 Ok(PublishItemResult::Created)
520 }
521}
522
523fn print_single_publish_result(
524 queue: &QueueFile,
525 task_id: &str,
526 result: PublishItemResult,
527 labels: &[String],
528 assignees: &[String],
529 repo: Option<&str>,
530) -> Result<()> {
531 let task = find_task(queue, task_id)?;
532 let title = format!("{}: {}", task.id, task.title);
533 let body = super::export::render_task_as_github_issue_body(task);
534
535 println!("=== DRY RUN ===");
536 println!("Target task: {task_id}");
537 println!("Title: {title}");
538 println!();
539 println!("Body:");
540 println!("{body}");
541
542 match result {
543 PublishItemResult::Created => println!("Would create new GitHub issue."),
544 PublishItemResult::Updated => {
545 let existing_url = fetch_custom_field(&task.custom_fields, GITHUB_ISSUE_URL_KEY)
546 .unwrap_or_else(|| "unknown".to_string());
547 println!("Would update existing issue: {existing_url}");
548 }
549 PublishItemResult::SkippedUnchanged => {
550 println!("Would skip task; issue payload is already synced.");
551 }
552 PublishItemResult::Failed(err) => return Err(err),
553 }
554
555 if let Some(repo) = repo {
556 println!("Target repo: {repo}");
557 }
558 if !labels.is_empty() {
559 println!("Labels: {}", labels.join(", "));
560 }
561 if !assignees.is_empty() {
562 println!("Assignees: {}", assignees.join(", "));
563 }
564
565 Ok(())
566}
567
568fn print_publish_many_plan(task_ids: &[String], results: &[(String, PublishItemResult)]) {
569 println!("publish-many plan for {} task(s):", task_ids.len());
570 for (task_id, result) in results {
571 let label = match result {
572 PublishItemResult::Created => "CREATE",
573 PublishItemResult::Updated => "UPDATE",
574 PublishItemResult::SkippedUnchanged => "SKIP",
575 PublishItemResult::Failed(_) => "ERROR",
576 };
577
578 if let PublishItemResult::Failed(err) = result {
579 println!(" [{label}] {task_id}: {err}");
580 } else {
581 println!(" [{label}] {task_id}");
582 }
583 }
584}
585
586fn print_publish_many_task_result(task_id: &str, result: &PublishItemResult) {
587 let label = match result {
588 PublishItemResult::Created => "CREATE",
589 PublishItemResult::Updated => "UPDATE",
590 PublishItemResult::SkippedUnchanged => "SKIP",
591 PublishItemResult::Failed(_) => "ERROR",
592 };
593
594 if let PublishItemResult::Failed(err) = result {
595 println!(" [{label}] {task_id}: {err}");
596 } else {
597 println!(" [{label}] {task_id}");
598 }
599}
600
601fn print_publish_many_summary(summary: &PublishManySummary, dry_run: bool) {
602 let mode = if dry_run { "dry-run" } else { "execution" };
603 println!(
604 "publish-many {mode} summary: selected={} created={} updated={} skipped={} failed={}",
605 summary.selected, summary.created, summary.updated, summary.skipped, summary.failed,
606 );
607}
608
609fn accumulate_publish_result(summary: &mut PublishManySummary, result: &PublishItemResult) {
610 match result {
611 PublishItemResult::Created => summary.created += 1,
612 PublishItemResult::Updated => summary.updated += 1,
613 PublishItemResult::SkippedUnchanged => summary.skipped += 1,
614 PublishItemResult::Failed(_) => summary.failed += 1,
615 }
616}
617
618fn confirm_execution(summary: &PublishManySummary) -> Result<bool> {
619 println!("About to execute {} task(s):", summary.selected);
620 println!(" create: {}", summary.created);
621 println!(" update: {}", summary.updated);
622 println!(" skip: {}", summary.skipped);
623 print!("Proceed with publish-many execution? [y/N]: ");
624 io::stdout().flush().context("flush confirmation prompt")?;
625
626 let mut response = String::new();
627 io::stdin()
628 .lock()
629 .read_line(&mut response)
630 .context("read confirmation input")?;
631 Ok(matches!(
632 response.trim().to_lowercase().as_str(),
633 "y" | "yes"
634 ))
635}
636
637fn is_terminal_context() -> bool {
638 io::stdin().is_terminal() && io::stdout().is_terminal()
639}
640
641fn fetch_custom_field(
642 custom_fields: &std::collections::HashMap<String, String>,
643 key: &str,
644) -> Option<String> {
645 custom_fields
646 .get(key)
647 .map(|value| value.trim())
648 .filter(|value| !value.is_empty())
649 .map(ToString::to_string)
650}
651
652fn find_task<'a>(queue: &'a QueueFile, task_id: &str) -> Result<&'a Task> {
653 let task_id = task_id.trim();
654 queue
655 .tasks
656 .iter()
657 .find(|task| task.id.trim() == task_id)
658 .ok_or_else(|| {
659 anyhow::anyhow!(
660 "{}",
661 crate::error_messages::task_not_found_in_queue(task_id)
662 )
663 })
664}
665
666fn find_task_mut<'a>(queue: &'a mut QueueFile, task_id: &str) -> Result<&'a mut Task> {
667 let task_id = task_id.trim();
668 queue
669 .tasks
670 .iter_mut()
671 .find(|task| task.id.trim() == task_id)
672 .ok_or_else(|| {
673 anyhow::anyhow!(
674 "{}",
675 crate::error_messages::task_not_found_in_queue(task_id)
676 )
677 })
678}