Skip to main content

ralph/cli/queue/
issue.rs

1//! `ralph queue issue` subcommand for publishing tasks to GitHub Issues.
2//!
3//! Responsibilities:
4//! - Publish one or many queue tasks as GitHub Issues.
5//! - Persist GitHub metadata (`github_issue_url`, `github_issue_number`, sync hash)
6//!   back into task `custom_fields` for incremental sync.
7//! - Provide bulk dry-run / execute behavior with filtering and confirmation.
8//!
9//! Not handled here:
10//! - GitHub Projects automation.
11//! - Queue schema migrations.
12//! - GUI-specific issue publishing workflows.
13//!
14//! Invariants/assumptions:
15//! - `gh` CLI must be available and authenticated for execute mode.
16//! - Task IDs must exist in the active queue.
17//! - Queue writes occur only while queue lock is held.
18
19use 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;
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 (create or update) a single task as a GitHub issue.
52    Publish(QueueIssuePublishArgs),
53
54    /// Publish (create or update) many tasks as GitHub issues.
55    PublishMany(QueueIssuePublishManyArgs),
56}
57
58/// Arguments for `ralph queue issue publish`.
59#[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    /// Task ID to publish.
71    pub task_id: String,
72
73    /// Dry run: print rendered title/body and the action that would be executed.
74    #[arg(long)]
75    pub dry_run: bool,
76
77    /// Labels to apply (repeatable).
78    #[arg(long)]
79    pub label: Vec<String>,
80
81    /// Assignees to apply (repeatable). Supports @me for self-assignment.
82    #[arg(long)]
83    pub assignee: Vec<String>,
84
85    /// Target repository (OWNER/REPO format). Optional; uses current repo by default.
86    #[arg(long)]
87    pub repo: Option<String>,
88}
89
90/// Arguments for `ralph queue issue publish-many`.
91#[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    /// Filter by status (repeatable). Defaults to all non-draft statuses.
101    #[arg(long)]
102    pub status: Vec<StatusArg>,
103
104    /// Filter by tag (repeatable).
105    #[arg(long)]
106    pub tag: Vec<String>,
107
108    /// Filter by task ID regular expression.
109    #[arg(long)]
110    pub id_pattern: Option<String>,
111
112    /// Preview mode (default). No writes to queue or GitHub.
113    #[arg(long)]
114    pub dry_run: bool,
115
116    /// Execute publishes and persist GitHub metadata.
117    #[arg(long)]
118    pub execute: bool,
119
120    /// Labels to apply for each issue in the bulk run.
121    #[arg(long)]
122    pub label: Vec<String>,
123
124    /// Assignees to apply for each issue in the bulk run.
125    #[arg(long)]
126    pub assignee: Vec<String>,
127
128    /// Target repository (OWNER/REPO format). Optional; uses current repo by default.
129    #[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(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    // Create undo snapshot before mutation
177    crate::undo::create_undo_snapshot(resolved, &format!("queue issue publish {}", task_id))?;
178
179    let (mut queue_file, _done_file) = 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(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    // Create undo snapshot BEFORE any mutations
279    crate::undo::create_undo_snapshot(resolved, "queue issue publish-many")?;
280
281    let (mut queue_file, _done_file) = 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}