use std::collections::BTreeMap;
use std::path::Path;
use crate::bulk::plan::BatchPlan;
use crate::bulk::{
self, ApplyReport, ArchiveRequest, Confirmation, IssuePatch, PairOutcome, PatchValue,
RelationshipOp, Selection, SelectionScope, SelectionSource,
};
use crate::cli::ExitCode;
use crate::commands::parse_priority;
use crate::error::{Result, TixError};
use crate::model::Priority;
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectionRequest {
pub source: SelectionSource,
pub selectors: Vec<String>,
pub query: Option<String>,
pub scope: SelectionScope,
}
impl SelectionRequest {
pub fn new(
selectors: &[String],
filter: Option<&str>,
search: Option<&str>,
scope: SelectionScope,
) -> Result<Self> {
let named: Vec<(&str, &str)> = [
(
"--select",
selectors.first().map(|s| s.as_str()).unwrap_or(""),
),
("--filter", filter.unwrap_or("")),
("--search", search.unwrap_or("")),
]
.into_iter()
.filter(|(_, value)| !value.trim().is_empty())
.collect();
if named.len() != 1 {
return Err(TixError::InvalidInput(format!(
"name exactly one of --select, --filter or --search \
({} given) — they are different grammars, so a bulk operation will \
not guess which issues you meant",
if named.is_empty() {
"none".to_string()
} else {
named
.iter()
.map(|(flag, _)| *flag)
.collect::<Vec<_>>()
.join(" and ")
}
)));
}
let (source, query) = match named[0].0 {
"--select" => (SelectionSource::Explicit, None),
"--filter" => (SelectionSource::Filter, Some(named[0].1.trim().to_string())),
_ => (SelectionSource::Search, Some(named[0].1.trim().to_string())),
};
Ok(Self {
source,
selectors: split_selectors(selectors),
query,
scope,
})
}
}
fn split_selectors(raw: &[String]) -> Vec<String> {
raw.iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PatchFlags {
pub status: Option<String>,
pub priority: Option<String>,
pub assignee: Option<String>,
pub labels: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub estimate: Option<String>,
pub cycles: Option<String>,
pub milestones: Option<String>,
pub clear: Vec<String>,
}
const CLEARABLE: [&str; 8] = [
"priority",
"assignee",
"labels",
"start-date",
"due-date",
"estimate",
"cycles",
"milestones",
];
impl PatchFlags {
pub fn clearable() -> &'static [&'static str] {
&CLEARABLE
}
pub fn field_rows() -> &'static [&'static str] {
&[
"status",
"priority",
"assignee",
"labels",
"start-date",
"due-date",
"estimate",
"cycles",
"milestones",
"clear",
]
}
pub fn is_clearable(field: &str) -> bool {
CLEARABLE.contains(&field)
}
pub fn is_empty(&self) -> bool {
self.status.is_none()
&& self.priority.is_none()
&& self.assignee.is_none()
&& self.labels.is_none()
&& self.start_date.is_none()
&& self.due_date.is_none()
&& self.estimate.is_none()
&& self.cycles.is_none()
&& self.milestones.is_none()
&& self.clear.is_empty()
}
pub fn to_patch(&self) -> Result<IssuePatch> {
let text = |value: &Option<String>| -> PatchValue<String> {
match value {
Some(v) if !v.trim().is_empty() => PatchValue::Set(v.trim().to_string()),
_ => PatchValue::Unchanged,
}
};
let priority = match &self.priority {
Some(v) if !v.trim().is_empty() => {
PatchValue::Set(parse_priority(v.trim())?)
}
_ => PatchValue::Unchanged,
};
let list = |value: &Option<String>| -> PatchValue<Vec<String>> {
match value {
Some(v) if !v.trim().is_empty() => PatchValue::Set(
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
),
_ => PatchValue::Unchanged,
}
};
let mut patch = IssuePatch {
status: text(&self.status),
priority,
assignee: text(&self.assignee),
labels: list(&self.labels),
start_date: text(&self.start_date),
due_date: text(&self.due_date),
estimate: text(&self.estimate),
cycles: list(&self.cycles),
milestones: list(&self.milestones),
..IssuePatch::default()
};
for field in &self.clear {
if !Self::is_clearable(field) {
return Err(TixError::InvalidInput(format!(
"cannot clear '{field}' — clearable fields: {}",
CLEARABLE.join(", ")
)));
}
match field.as_str() {
"priority" => patch.priority = PatchValue::Clear,
"assignee" => patch.assignee = PatchValue::Clear,
"labels" => patch.labels = PatchValue::Clear,
"start-date" => patch.start_date = PatchValue::Clear,
"due-date" => patch.due_date = PatchValue::Clear,
"estimate" => patch.estimate = PatchValue::Clear,
"cycles" => patch.cycles = PatchValue::Clear,
"milestones" => patch.milestones = PatchValue::Clear,
_ => unreachable!("CLEARABLE and this match must list the same fields"),
}
}
Ok(patch)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Preview,
Apply,
}
impl Mode {
pub fn verb(self, done: &'static str) -> &'static str {
match self {
Self::Preview => {
let _ = done;
"would"
}
Self::Apply => done,
}
}
}
pub type BulkOutcome = bulk::ItemOutcome;
pub fn run_edit(
store: &Store,
request: &SelectionRequest,
flags: &PatchFlags,
mode: Mode,
) -> Result<ExitCode> {
let patch = flags.to_patch()?;
if patch.is_empty() {
return Err(TixError::InvalidInput(format!(
"no fields to change — name at least one of: {}",
IssuePatch::field_names().join(", ")
)));
}
let selection = resolve(store, request)?;
let plan = build(store, selection, patch)?;
if mode == Mode::Preview {
print!("{}", render_plan(&plan, Mode::Preview));
return Ok(exit_code_for_plan(&plan));
}
let mut confirmed = plan.clone();
confirmed.confirmation = Some(Confirmation::issue(&plan));
let report = bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None)?;
print!("{}", render_report(&report, &confirmed, Mode::Apply));
Ok(exit_code_for_report(&report))
}
pub fn run_pair(
store: &Store,
request: &SelectionRequest,
target: Option<&str>,
kind: Option<&str>,
link: bool,
mode: Mode,
) -> Result<ExitCode> {
let kind = crate::commands::link::require_kind(kind)?;
let selection = resolve(store, request)?;
if selection.is_empty() {
return Err(TixError::NotFound(
"the selection is empty, so there is nothing to pair".to_string(),
));
}
let mut outcomes: Vec<(String, PairOutcome)> = Vec::new();
let mut refused = 0usize;
for issue in selection.issues() {
let op = if link {
RelationshipOp::link(issue.number.to_string(), target.unwrap_or_default(), kind)
} else {
RelationshipOp::unlink(issue.number.to_string(), target.unwrap_or_default(), kind)
};
let mut plan = build(
store,
Selection::from_issues(vec![issue.clone()], request.source, request.scope),
IssuePatch {
relationship: Some(op.clone()),
..IssuePatch::default()
},
)?;
if mode == Mode::Preview {
let resolution = bulk::pair::resolve_pair(store, &op);
match &resolution.diagnostic {
Some(diagnostic) => {
refused += 1;
println!(
"#{} {} -> refused: {}",
issue.number,
issue.title,
resolution.requested.verb()
);
let _ = diagnostic;
}
None => println!(
"#{} {} -> would {} {} #{} ({})",
issue.number,
issue.title,
op.verb(),
issue.number,
resolution.edge.expect("checked").to,
kind.as_str()
),
}
continue;
}
plan.confirmation = Some(Confirmation::issue(&plan));
let outcome = bulk::apply_pair(store, &plan)?;
if matches!(outcome, PairOutcome::Refused(_)) {
refused += 1;
}
outcomes.push((format!("#{} {}", issue.number, issue.title), outcome));
}
if mode == Mode::Preview {
return Ok(if refused > 0 {
ExitCode::UsageConfig
} else {
ExitCode::Success
});
}
for (label, outcome) in &outcomes {
println!("{label} -> {}", outcome.as_line());
}
let applied = outcomes.iter().filter(|(_, o)| o.is_applied()).count();
println!(
"{} relationship operation{} applied, {refused} refused",
applied,
if applied == 1 { "" } else { "s" }
);
Ok(if refused > 0 || applied < outcomes.len() {
ExitCode::UsageConfig
} else {
ExitCode::Success
})
}
pub fn run_archive(store: &Store, request: &SelectionRequest, mode: Mode) -> Result<ExitCode> {
let selection = resolve(store, request)?;
let plan = build(
store,
selection.clone(),
IssuePatch {
archive: Some(ArchiveRequest::archive()),
..IssuePatch::default()
},
)?;
if mode == Mode::Preview {
print!("{}", render_plan(&plan, Mode::Preview));
return Ok(exit_code_for_plan(&plan));
}
let mut confirmed = plan.clone();
confirmed.confirmation = Some(Confirmation::issue(&plan));
let report = bulk::apply_archive(store, &confirmed)?;
print!("{}", render_report(&report, &confirmed, Mode::Apply));
Ok(exit_code_for_report(&report))
}
pub fn resolve(store: &Store, request: &SelectionRequest) -> Result<Selection> {
Selection::resolve(
store,
&request.selectors,
request.query.as_deref(),
request.source,
request.scope,
)
}
pub fn build(store: &Store, selection: Selection, patch: IssuePatch) -> Result<BatchPlan> {
let mut plan = bulk::plan::build_plan(
&store.config,
&store.issues_dir,
selection,
patch,
|op: &RelationshipOp| bulk::pair::plan_for(store, op),
)?;
crate::wip::bulk::annotate_plan(&mut plan, &store.config, &store.list()?);
Ok(plan)
}
pub fn render_plan(plan: &BatchPlan, mode: Mode) -> String {
let mut out = String::new();
out.push_str(&render_selection(plan));
for item in &plan.items {
if item.changes.is_empty() && item.diagnostic.is_none() {
continue;
}
out.push_str(&format!("#{} {}\n", item.number, item.title));
if let Some(diagnostic) = &item.diagnostic {
out.push_str(&format!(
" refused [{}]: {}\n",
diagnostic.field, diagnostic.message
));
continue;
}
for (field, change) in &item.changes {
out.push_str(&format!(" {field}: {}\n", render_change(change)));
}
if let Some(move_path) = &item.path_change {
out.push_str(&format!(
" path: {} -> {}\n",
move_path.from, move_path.to
));
}
for intent in &item.history {
out.push_str(&format!(
" history: {} ({})\n",
intent.kind, intent.detail
));
}
if let Some(wip) = &item.wip
&& wip.decision.outcome != crate::wip::WipOutcome::Allow
{
let first = crate::wip::render_decision(&wip.decision);
let head = first.lines().next().unwrap_or_default();
out.push_str(&format!(" wip: {head}\n"));
for diagnostic in &wip.decision.diagnostics {
out.push_str(&format!(
" {}: {}\n",
diagnostic.kind.as_str(),
diagnostic.message
));
}
}
let _ = mode;
}
if let Some(pair) = &plan.pair {
match (&pair.from, &pair.to) {
(Ok(from), Ok(to)) => {
out.push_str(&format!("pair: {} #{from} -> #{to}\n", pair.verb()))
}
_ => {
if let Some(diagnostic) = &pair.diagnostic {
out.push_str(&format!("pair: refused — {diagnostic}\n"));
} else {
out.push_str(&format!("pair: refused — {}\n", pair.verb()));
}
}
}
}
if let Some(archive) = &plan.archive {
let count = plan.selection.len();
out.push_str(&format!(
"archive: would {} {count} issue{}\n",
if archive.archive {
"archive"
} else {
"restore"
},
if count == 1 { "" } else { "s" }
));
}
for duplicate in plan.selection.duplicates() {
out.push_str(&format!(
"note: selector '{duplicate}' named an issue already selected\n"
));
}
out.push_str(&format!("{}\n", plan.summary()));
out
}
pub fn render_report(report: &ApplyReport, plan: &BatchPlan, mode: Mode) -> String {
let mut out = String::new();
out.push_str(&render_selection(plan));
let by_number: BTreeMap<u32, &str> = plan
.items
.iter()
.map(|i| (i.number, i.title.as_str()))
.collect();
for (number, outcome) in &report.outcomes {
let title = by_number.get(number).copied().unwrap_or("");
out.push_str(&outcome.as_line(*number, title));
out.push('\n');
}
out.push_str(&format!("{}\n", report.summary()));
let moved: Vec<String> = report
.outcomes
.iter()
.filter_map(|(n, o)| match o {
BulkOutcome::Applied { filename } => Some(format!("#{n} -> {filename}")),
_ => None,
})
.collect();
if !moved.is_empty() {
out.push_str("final paths:\n");
for line in moved {
out.push_str(&format!(" {line}\n"));
}
}
let _ = mode;
out
}
fn render_selection(plan: &BatchPlan) -> String {
let selection = &plan.selection;
let mut out = format!(
"selection: {} issue{} ({}, {} scope)\n",
selection.len(),
if selection.len() == 1 { "" } else { "s" },
selection.source().as_str(),
selection.scope().as_str()
);
let listing: Vec<String> = selection
.issues()
.iter()
.map(|i| format!(" #{} {}", i.number, i.title))
.collect();
if !listing.is_empty() {
out.push_str(&listing.join("\n"));
out.push('\n');
}
out
}
fn render_change(change: &bulk::FieldChange) -> String {
use bulk::FieldChange::{Clear, NoChange, Set};
match change {
Set { from, to } => format!("{from:?} -> {to}"),
Clear { from } => format!("{from:?} -> (empty)"),
NoChange { value } => format!("{value:?} (already in that state)"),
}
}
pub fn exit_code_for_plan(plan: &BatchPlan) -> ExitCode {
let refused = plan.blocked().count()
+ usize::from(plan.pair.as_ref().is_some_and(|p| p.diagnostic.is_some()));
if refused > 0 {
ExitCode::UsageConfig
} else {
ExitCode::Success
}
}
pub fn exit_code_for_report(report: &ApplyReport) -> ExitCode {
let not_applied = report.noop() + report.rejected() + report.failed();
if not_applied > 0 {
ExitCode::UsageConfig
} else {
ExitCode::Success
}
}
pub fn agents_doc() -> String {
use crate::bulk::metadata;
let operations = metadata::BulkOperation::ALL
.iter()
.map(|op| {
let mut line = format!("tix bulk {:<8} --select <list>", op.as_str());
if op.needs_target() {
line.push_str(" --target <sel>");
}
if op.needs_kind() {
line.push_str(" --type <kind>");
}
if op.is_field_patch() {
line.push_str(" <patch flags>");
}
line.push_str(" [--apply]");
line
})
.collect::<Vec<_>>()
.join("\n");
let out = format!(
"### `tix bulk`\n\n\
Batch editing over a selection. **Preview is the default**; `--apply` writes.\n\n\
```text\n\
{operations}\n\
```\n\n\
- Name **exactly one** of `--select <list>`, `--filter <query>` or\n \
`--search <query>`. They are different grammars, so a bulk operation will\n \
not guess which issues you meant.\n\
- Patch flags (the whole whitelist):\n {}\n \
`title` and `body` are deliberately **not** in it: a batch title rewrite is\n \
the one operation nobody has asked for and everybody would regret.\n\
- `--clear <field>` takes one of:\n {}\n \
One per invocation — a second is refused rather than silently dropped. A\n \
**blank** flag value is *unchanged*, never \"set to empty\": to empty a field,\n \
name it in `--clear`.\n\
- `archive` delegates to the store's archive service: each selected issue's\n \
file is renamed **in place** to a dot-prefixed name and exactly one History\n \
event records it. `number`, `status` and every other frontmatter field are\n \
left byte-identical, re-running reports the no-op, and nothing is deleted.\n\
- A **partial** batch exits non-zero and prints one outcome per selected\n \
issue. Items that failed are **not** rolled back, and the output does not\n \
imply that they are. Re-running is idempotent and reports the no-op.\n\
- Per-item statuses are the same four words on every surface:\n {}\n",
metadata::field_names().join(", "),
metadata::clearable_field_names().join(", "),
metadata::OUTCOME_STATUSES
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(", "),
);
out
}
pub fn run(
store: &Store,
action: &crate::cli::BulkAction,
flags: &crate::cli::BulkSelection,
mode: Mode,
) -> Result<ExitCode> {
let scope = match flags.scope.as_deref() {
Some(raw) => SelectionScope::parse(raw)?,
None => SelectionScope::Active,
};
let request = SelectionRequest::new(
&[flags.select.clone().unwrap_or_default()],
flags.filter.as_deref(),
flags.search.as_deref(),
scope,
)?;
match action {
crate::cli::BulkAction::Edit {
selection: _,
status,
priority,
assignee,
labels,
start_date,
due_date,
estimate,
cycles,
milestones,
clear,
} => run_edit(
store,
&request,
&PatchFlags {
status: status.clone(),
priority: priority.clone(),
assignee: assignee.clone(),
labels: labels.clone(),
start_date: start_date.clone(),
due_date: due_date.clone(),
estimate: estimate.clone(),
cycles: cycles.clone(),
milestones: milestones.clone(),
clear: clear.iter().cloned().collect(),
},
mode,
),
crate::cli::BulkAction::Link {
selection: _,
target,
r#type,
} => run_pair(store, &request, Some(target), r#type.as_deref(), true, mode),
crate::cli::BulkAction::Unlink {
selection: _,
target,
r#type,
} => run_pair(
store,
&request,
Some(target),
r#type.as_deref(),
false,
mode,
),
crate::cli::BulkAction::Archive { selection: _ } => run_archive(store, &request, mode),
}
}
pub fn issues_dir(store: &Store) -> &Path {
&store.issues_dir
}
const _: Option<Priority> = None;