1use std::collections::BTreeMap;
44use std::path::Path;
45
46use crate::bulk::plan::BatchPlan;
47use crate::bulk::{
48 self, ApplyReport, ArchiveRequest, Confirmation, IssuePatch, PairOutcome, PatchValue,
49 RelationshipOp, Selection, SelectionScope, SelectionSource,
50};
51use crate::cli::ExitCode;
52use crate::commands::parse_priority;
53use crate::error::{Result, TixError};
54use crate::model::Priority;
55use crate::storage::Store;
56
57#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SelectionRequest {
66 pub source: SelectionSource,
67 pub selectors: Vec<String>,
69 pub query: Option<String>,
71 pub scope: SelectionScope,
72}
73
74impl SelectionRequest {
75 pub fn new(
81 selectors: &[String],
82 filter: Option<&str>,
83 search: Option<&str>,
84 scope: SelectionScope,
85 ) -> Result<Self> {
86 let named: Vec<(&str, &str)> = [
87 (
88 "--select",
89 selectors.first().map(|s| s.as_str()).unwrap_or(""),
90 ),
91 ("--filter", filter.unwrap_or("")),
92 ("--search", search.unwrap_or("")),
93 ]
94 .into_iter()
95 .filter(|(_, value)| !value.trim().is_empty())
96 .collect();
97 if named.len() != 1 {
98 return Err(TixError::InvalidInput(format!(
99 "name exactly one of --select, --filter or --search \
100 ({} given) — they are different grammars, so a bulk operation will \
101 not guess which issues you meant",
102 if named.is_empty() {
103 "none".to_string()
104 } else {
105 named
106 .iter()
107 .map(|(flag, _)| *flag)
108 .collect::<Vec<_>>()
109 .join(" and ")
110 }
111 )));
112 }
113 let (source, query) = match named[0].0 {
114 "--select" => (SelectionSource::Explicit, None),
115 "--filter" => (SelectionSource::Filter, Some(named[0].1.trim().to_string())),
116 _ => (SelectionSource::Search, Some(named[0].1.trim().to_string())),
117 };
118 Ok(Self {
119 source,
120 selectors: split_selectors(selectors),
121 query,
122 scope,
123 })
124 }
125}
126
127fn split_selectors(raw: &[String]) -> Vec<String> {
133 raw.iter()
134 .flat_map(|value| value.split(','))
135 .map(str::trim)
136 .filter(|s| !s.is_empty())
137 .map(str::to_string)
138 .collect()
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct PatchFlags {
148 pub status: Option<String>,
149 pub priority: Option<String>,
150 pub assignee: Option<String>,
151 pub labels: Option<String>,
152 pub start_date: Option<String>,
153 pub due_date: Option<String>,
154 pub estimate: Option<String>,
155 pub cycles: Option<String>,
156 pub milestones: Option<String>,
157 pub clear: Vec<String>,
159}
160
161const CLEARABLE: [&str; 8] = [
167 "priority",
168 "assignee",
169 "labels",
170 "start-date",
171 "due-date",
172 "estimate",
173 "cycles",
174 "milestones",
175];
176
177impl PatchFlags {
184 pub fn clearable() -> &'static [&'static str] {
186 &CLEARABLE
187 }
188
189 pub fn field_rows() -> &'static [&'static str] {
196 &[
197 "status",
198 "priority",
199 "assignee",
200 "labels",
201 "start-date",
202 "due-date",
203 "estimate",
204 "cycles",
205 "milestones",
206 "clear",
207 ]
208 }
209
210 pub fn is_clearable(field: &str) -> bool {
212 CLEARABLE.contains(&field)
213 }
214
215 pub fn is_empty(&self) -> bool {
217 self.status.is_none()
218 && self.priority.is_none()
219 && self.assignee.is_none()
220 && self.labels.is_none()
221 && self.start_date.is_none()
222 && self.due_date.is_none()
223 && self.estimate.is_none()
224 && self.cycles.is_none()
225 && self.milestones.is_none()
226 && self.clear.is_empty()
227 }
228
229 pub fn to_patch(&self) -> Result<IssuePatch> {
236 let text = |value: &Option<String>| -> PatchValue<String> {
237 match value {
238 Some(v) if !v.trim().is_empty() => PatchValue::Set(v.trim().to_string()),
239 _ => PatchValue::Unchanged,
240 }
241 };
242 let priority = match &self.priority {
243 Some(v) if !v.trim().is_empty() => {
244 PatchValue::Set(parse_priority(v.trim())?)
247 }
248 _ => PatchValue::Unchanged,
249 };
250 let list = |value: &Option<String>| -> PatchValue<Vec<String>> {
251 match value {
252 Some(v) if !v.trim().is_empty() => PatchValue::Set(
253 v.split(',')
254 .map(str::trim)
255 .filter(|s| !s.is_empty())
256 .map(str::to_string)
257 .collect(),
258 ),
259 _ => PatchValue::Unchanged,
260 }
261 };
262 let mut patch = IssuePatch {
263 status: text(&self.status),
264 priority,
265 assignee: text(&self.assignee),
266 labels: list(&self.labels),
267 start_date: text(&self.start_date),
268 due_date: text(&self.due_date),
269 estimate: text(&self.estimate),
270 cycles: list(&self.cycles),
271 milestones: list(&self.milestones),
272 ..IssuePatch::default()
273 };
274 for field in &self.clear {
275 if !Self::is_clearable(field) {
276 return Err(TixError::InvalidInput(format!(
277 "cannot clear '{field}' — clearable fields: {}",
278 CLEARABLE.join(", ")
279 )));
280 }
281 match field.as_str() {
284 "priority" => patch.priority = PatchValue::Clear,
285 "assignee" => patch.assignee = PatchValue::Clear,
286 "labels" => patch.labels = PatchValue::Clear,
287 "start-date" => patch.start_date = PatchValue::Clear,
288 "due-date" => patch.due_date = PatchValue::Clear,
289 "estimate" => patch.estimate = PatchValue::Clear,
290 "cycles" => patch.cycles = PatchValue::Clear,
291 "milestones" => patch.milestones = PatchValue::Clear,
292 _ => unreachable!("CLEARABLE and this match must list the same fields"),
296 }
297 }
298 Ok(patch)
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum Mode {
305 Preview,
307 Apply,
309}
310
311impl Mode {
312 pub fn verb(self, done: &'static str) -> &'static str {
315 match self {
316 Self::Preview => {
317 let _ = done;
318 "would"
319 }
320 Self::Apply => done,
321 }
322 }
323}
324
325pub type BulkOutcome = bulk::ItemOutcome;
331
332pub fn run_edit(
334 store: &Store,
335 request: &SelectionRequest,
336 flags: &PatchFlags,
337 mode: Mode,
338) -> Result<ExitCode> {
339 let patch = flags.to_patch()?;
340 if patch.is_empty() {
341 return Err(TixError::InvalidInput(format!(
342 "no fields to change — name at least one of: {}",
343 IssuePatch::field_names().join(", ")
344 )));
345 }
346 let selection = resolve(store, request)?;
347 let plan = build(store, selection, patch)?;
348 if mode == Mode::Preview {
349 print!("{}", render_plan(&plan, Mode::Preview));
350 return Ok(exit_code_for_plan(&plan));
351 }
352 let mut confirmed = plan.clone();
353 confirmed.confirmation = Some(Confirmation::issue(&plan));
354 let report = bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None)?;
355 print!("{}", render_report(&report, &confirmed, Mode::Apply));
356 Ok(exit_code_for_report(&report))
357}
358
359pub fn run_pair(
367 store: &Store,
368 request: &SelectionRequest,
369 target: Option<&str>,
370 kind: Option<&str>,
371 link: bool,
372 mode: Mode,
373) -> Result<ExitCode> {
374 let kind = crate::commands::link::require_kind(kind)?;
375 let selection = resolve(store, request)?;
376 if selection.is_empty() {
377 return Err(TixError::NotFound(
378 "the selection is empty, so there is nothing to pair".to_string(),
379 ));
380 }
381 let mut outcomes: Vec<(String, PairOutcome)> = Vec::new();
382 let mut refused = 0usize;
383 for issue in selection.issues() {
384 let op = if link {
385 RelationshipOp::link(issue.number.to_string(), target.unwrap_or_default(), kind)
386 } else {
387 RelationshipOp::unlink(issue.number.to_string(), target.unwrap_or_default(), kind)
388 };
389 let mut plan = build(
390 store,
391 Selection::from_issues(vec![issue.clone()], request.source, request.scope),
392 IssuePatch {
393 relationship: Some(op.clone()),
394 ..IssuePatch::default()
395 },
396 )?;
397 if mode == Mode::Preview {
398 let resolution = bulk::pair::resolve_pair(store, &op);
399 match &resolution.diagnostic {
400 Some(diagnostic) => {
401 refused += 1;
402 println!(
403 "#{} {} -> refused: {}",
404 issue.number,
405 issue.title,
406 resolution.requested.verb()
407 );
408 let _ = diagnostic;
409 }
410 None => println!(
411 "#{} {} -> would {} {} #{} ({})",
412 issue.number,
413 issue.title,
414 op.verb(),
415 issue.number,
416 resolution.edge.expect("checked").to,
417 kind.as_str()
418 ),
419 }
420 continue;
421 }
422 plan.confirmation = Some(Confirmation::issue(&plan));
423 let outcome = bulk::apply_pair(store, &plan)?;
424 if matches!(outcome, PairOutcome::Refused(_)) {
425 refused += 1;
426 }
427 outcomes.push((format!("#{} {}", issue.number, issue.title), outcome));
428 }
429 if mode == Mode::Preview {
430 return Ok(if refused > 0 {
431 ExitCode::UsageConfig
432 } else {
433 ExitCode::Success
434 });
435 }
436 for (label, outcome) in &outcomes {
437 println!("{label} -> {}", outcome.as_line());
438 }
439 let applied = outcomes.iter().filter(|(_, o)| o.is_applied()).count();
440 println!(
441 "{} relationship operation{} applied, {refused} refused",
442 applied,
443 if applied == 1 { "" } else { "s" }
444 );
445 Ok(if refused > 0 || applied < outcomes.len() {
446 ExitCode::UsageConfig
447 } else {
448 ExitCode::Success
449 })
450}
451
452pub fn run_archive(store: &Store, request: &SelectionRequest, mode: Mode) -> Result<ExitCode> {
459 let selection = resolve(store, request)?;
460 let plan = build(
461 store,
462 selection.clone(),
463 IssuePatch {
464 archive: Some(ArchiveRequest::archive()),
465 ..IssuePatch::default()
466 },
467 )?;
468 if mode == Mode::Preview {
469 print!("{}", render_plan(&plan, Mode::Preview));
470 return Ok(exit_code_for_plan(&plan));
471 }
472 let mut confirmed = plan.clone();
473 confirmed.confirmation = Some(Confirmation::issue(&plan));
474 let report = bulk::apply_archive(store, &confirmed)?;
475 print!("{}", render_report(&report, &confirmed, Mode::Apply));
476 Ok(exit_code_for_report(&report))
477}
478
479pub fn resolve(store: &Store, request: &SelectionRequest) -> Result<Selection> {
486 Selection::resolve(
487 store,
488 &request.selectors,
489 request.query.as_deref(),
490 request.source,
491 request.scope,
492 )
493}
494
495pub fn build(store: &Store, selection: Selection, patch: IssuePatch) -> Result<BatchPlan> {
501 let mut plan = bulk::plan::build_plan(
502 &store.config,
503 &store.issues_dir,
504 selection,
505 patch,
506 |op: &RelationshipOp| bulk::pair::plan_for(store, op),
507 )?;
508 crate::wip::bulk::annotate_plan(&mut plan, &store.config, &store.list()?);
511 Ok(plan)
512}
513
514pub fn render_plan(plan: &BatchPlan, mode: Mode) -> String {
522 let mut out = String::new();
523 out.push_str(&render_selection(plan));
524 for item in &plan.items {
525 if item.changes.is_empty() && item.diagnostic.is_none() {
526 continue;
527 }
528 out.push_str(&format!("#{} {}\n", item.number, item.title));
529 if let Some(diagnostic) = &item.diagnostic {
538 out.push_str(&format!(
539 " refused [{}]: {}\n",
540 diagnostic.field, diagnostic.message
541 ));
542 continue;
543 }
544 for (field, change) in &item.changes {
545 out.push_str(&format!(" {field}: {}\n", render_change(change)));
546 }
547 if let Some(move_path) = &item.path_change {
548 out.push_str(&format!(
549 " path: {} -> {}\n",
550 move_path.from, move_path.to
551 ));
552 }
553 for intent in &item.history {
554 out.push_str(&format!(
555 " history: {} ({})\n",
556 intent.kind, intent.detail
557 ));
558 }
559 if let Some(wip) = &item.wip
565 && wip.decision.outcome != crate::wip::WipOutcome::Allow
566 {
567 let first = crate::wip::render_decision(&wip.decision);
568 let head = first.lines().next().unwrap_or_default();
569 out.push_str(&format!(" wip: {head}\n"));
570 for diagnostic in &wip.decision.diagnostics {
575 out.push_str(&format!(
579 " {}: {}\n",
580 diagnostic.kind.as_str(),
581 diagnostic.message
582 ));
583 }
584 }
585 let _ = mode;
586 }
587 if let Some(pair) = &plan.pair {
588 match (&pair.from, &pair.to) {
589 (Ok(from), Ok(to)) => {
590 out.push_str(&format!("pair: {} #{from} -> #{to}\n", pair.verb()))
591 }
592 _ => {
593 if let Some(diagnostic) = &pair.diagnostic {
594 out.push_str(&format!("pair: refused — {diagnostic}\n"));
595 } else {
596 out.push_str(&format!("pair: refused — {}\n", pair.verb()));
597 }
598 }
599 }
600 }
601 if let Some(archive) = &plan.archive {
602 let count = plan.selection.len();
606 out.push_str(&format!(
607 "archive: would {} {count} issue{}\n",
608 if archive.archive {
609 "archive"
610 } else {
611 "restore"
612 },
613 if count == 1 { "" } else { "s" }
614 ));
615 }
616 for duplicate in plan.selection.duplicates() {
617 out.push_str(&format!(
618 "note: selector '{duplicate}' named an issue already selected\n"
619 ));
620 }
621 out.push_str(&format!("{}\n", plan.summary()));
622 out
623}
624
625pub fn render_report(report: &ApplyReport, plan: &BatchPlan, mode: Mode) -> String {
627 let mut out = String::new();
628 out.push_str(&render_selection(plan));
629 let by_number: BTreeMap<u32, &str> = plan
630 .items
631 .iter()
632 .map(|i| (i.number, i.title.as_str()))
633 .collect();
634 for (number, outcome) in &report.outcomes {
635 let title = by_number.get(number).copied().unwrap_or("");
636 out.push_str(&outcome.as_line(*number, title));
637 out.push('\n');
638 }
639 out.push_str(&format!("{}\n", report.summary()));
642 let moved: Vec<String> = report
644 .outcomes
645 .iter()
646 .filter_map(|(n, o)| match o {
647 BulkOutcome::Applied { filename } => Some(format!("#{n} -> {filename}")),
648 _ => None,
649 })
650 .collect();
651 if !moved.is_empty() {
652 out.push_str("final paths:\n");
653 for line in moved {
654 out.push_str(&format!(" {line}\n"));
655 }
656 }
657 let _ = mode;
658 out
659}
660
661fn render_selection(plan: &BatchPlan) -> String {
668 let selection = &plan.selection;
669 let mut out = format!(
670 "selection: {} issue{} ({}, {} scope)\n",
671 selection.len(),
672 if selection.len() == 1 { "" } else { "s" },
673 selection.source().as_str(),
674 selection.scope().as_str()
675 );
676 let listing: Vec<String> = selection
677 .issues()
678 .iter()
679 .map(|i| format!(" #{} {}", i.number, i.title))
680 .collect();
681 if !listing.is_empty() {
682 out.push_str(&listing.join("\n"));
683 out.push('\n');
684 }
685 out
686}
687
688fn render_change(change: &bulk::FieldChange) -> String {
689 use bulk::FieldChange::{Clear, NoChange, Set};
690 match change {
691 Set { from, to } => format!("{from:?} -> {to}"),
692 Clear { from } => format!("{from:?} -> (empty)"),
693 NoChange { value } => format!("{value:?} (already in that state)"),
694 }
695}
696
697pub fn exit_code_for_plan(plan: &BatchPlan) -> ExitCode {
705 let refused = plan.blocked().count()
706 + usize::from(plan.pair.as_ref().is_some_and(|p| p.diagnostic.is_some()));
707 if refused > 0 {
708 ExitCode::UsageConfig
709 } else {
710 ExitCode::Success
711 }
712}
713
714pub fn exit_code_for_report(report: &ApplyReport) -> ExitCode {
717 let not_applied = report.noop() + report.rejected() + report.failed();
718 if not_applied > 0 {
719 ExitCode::UsageConfig
720 } else {
721 ExitCode::Success
722 }
723}
724
725pub fn agents_doc() -> String {
737 use crate::bulk::metadata;
738 let operations = metadata::BulkOperation::ALL
739 .iter()
740 .map(|op| {
741 let mut line = format!("tix bulk {:<8} --select <list>", op.as_str());
742 if op.needs_target() {
743 line.push_str(" --target <sel>");
744 }
745 if op.needs_kind() {
746 line.push_str(" --type <kind>");
747 }
748 if op.is_field_patch() {
749 line.push_str(" <patch flags>");
750 }
751 line.push_str(" [--apply]");
752 line
753 })
754 .collect::<Vec<_>>()
755 .join("\n");
756 let out = format!(
757 "### `tix bulk`\n\n\
758 Batch editing over a selection. **Preview is the default**; `--apply` writes.\n\n\
759 ```text\n\
760 {operations}\n\
761 ```\n\n\
762 - Name **exactly one** of `--select <list>`, `--filter <query>` or\n \
763 `--search <query>`. They are different grammars, so a bulk operation will\n \
764 not guess which issues you meant.\n\
765 - Patch flags (the whole whitelist):\n {}\n \
766 `title` and `body` are deliberately **not** in it: a batch title rewrite is\n \
767 the one operation nobody has asked for and everybody would regret.\n\
768 - `--clear <field>` takes one of:\n {}\n \
769 One per invocation — a second is refused rather than silently dropped. A\n \
770 **blank** flag value is *unchanged*, never \"set to empty\": to empty a field,\n \
771 name it in `--clear`.\n\
772 - `archive` delegates to the store's archive service: each selected issue's\n \
773 file is renamed **in place** to a dot-prefixed name and exactly one History\n \
774 event records it. `number`, `status` and every other frontmatter field are\n \
775 left byte-identical, re-running reports the no-op, and nothing is deleted.\n\
776 - A **partial** batch exits non-zero and prints one outcome per selected\n \
777 issue. Items that failed are **not** rolled back, and the output does not\n \
778 imply that they are. Re-running is idempotent and reports the no-op.\n\
779 - Per-item statuses are the same four words on every surface:\n {}\n",
780 metadata::field_names().join(", "),
781 metadata::clearable_field_names().join(", "),
782 metadata::OUTCOME_STATUSES
783 .iter()
784 .map(|s| format!("`{s}`"))
785 .collect::<Vec<_>>()
786 .join(", "),
787 );
788 out
789}
790
791pub fn run(
798 store: &Store,
799 action: &crate::cli::BulkAction,
800 flags: &crate::cli::BulkSelection,
801 mode: Mode,
802) -> Result<ExitCode> {
803 let scope = match flags.scope.as_deref() {
804 Some(raw) => SelectionScope::parse(raw)?,
805 None => SelectionScope::Active,
806 };
807 let request = SelectionRequest::new(
808 &[flags.select.clone().unwrap_or_default()],
809 flags.filter.as_deref(),
810 flags.search.as_deref(),
811 scope,
812 )?;
813 match action {
814 crate::cli::BulkAction::Edit {
815 selection: _,
816 status,
817 priority,
818 assignee,
819 labels,
820 start_date,
821 due_date,
822 estimate,
823 cycles,
824 milestones,
825 clear,
826 } => run_edit(
827 store,
828 &request,
829 &PatchFlags {
830 status: status.clone(),
831 priority: priority.clone(),
832 assignee: assignee.clone(),
833 labels: labels.clone(),
834 start_date: start_date.clone(),
835 due_date: due_date.clone(),
836 estimate: estimate.clone(),
837 cycles: cycles.clone(),
838 milestones: milestones.clone(),
839 clear: clear.iter().cloned().collect(),
840 },
841 mode,
842 ),
843 crate::cli::BulkAction::Link {
844 selection: _,
845 target,
846 r#type,
847 } => run_pair(store, &request, Some(target), r#type.as_deref(), true, mode),
848 crate::cli::BulkAction::Unlink {
849 selection: _,
850 target,
851 r#type,
852 } => run_pair(
853 store,
854 &request,
855 Some(target),
856 r#type.as_deref(),
857 false,
858 mode,
859 ),
860 crate::cli::BulkAction::Archive { selection: _ } => run_archive(store, &request, mode),
861 }
862}
863
864pub fn issues_dir(store: &Store) -> &Path {
873 &store.issues_dir
874}
875
876const _: Option<Priority> = None;