use std::cmp::Ordering;
use std::collections::BTreeMap;
use crate::bulk::{ApplyReport, FieldChange, ItemOutcome, SelectionScope, SelectionSource};
use crate::cli::{ExitCode, TriageAction};
use crate::commands::bulk::{self, SelectionRequest};
use crate::error::{Result, TixError};
use crate::search::Corpus;
use crate::storage::Store;
use crate::triage::batch;
use crate::triage::inbox::{self, InboxScope, QueueEntry, QueueMetrics};
use crate::triage::{
DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, TriageDecision, TriagePlan,
};
pub fn run(store: &Store, action: &TriageAction) -> Result<ExitCode> {
match action {
TriageAction::List { age, count } => {
print!("{}", render_list(store, *age, *count)?);
Ok(ExitCode::Success)
}
TriageAction::Status => {
print!("{}", render_status(store)?);
Ok(ExitCode::Success)
}
TriageAction::Accept {
issue,
reason,
to,
actor,
} => decide(
store,
DecisionKind::Accept,
issue,
reason.as_deref(),
actor.as_deref(),
to.as_deref(),
None,
),
TriageAction::Decline {
issue,
reason,
actor,
} => decide(
store,
DecisionKind::Decline,
issue,
reason.as_deref(),
actor.as_deref(),
None,
None,
),
TriageAction::Duplicate {
issue,
of,
reason,
actor,
} => decide(
store,
DecisionKind::Duplicate,
issue,
reason.as_deref(),
actor.as_deref(),
None,
Some(of),
),
TriageAction::Defer {
issue,
reason,
actor,
} => decide(
store,
DecisionKind::Defer,
issue,
reason.as_deref(),
actor.as_deref(),
None,
None,
),
TriageAction::Batch {
select,
filter,
decision,
reason,
actor,
of,
to,
apply,
} => run_batch(
store,
BatchFlags {
select: select.as_deref(),
filter: filter.as_deref(),
decision,
reason: reason.as_deref(),
actor: actor.as_deref(),
of: of.as_deref(),
to: to.as_deref(),
apply: *apply,
},
),
}
}
fn scope_of(store: &Store) -> Result<InboxScope> {
inbox::scope(&store.config)?.ok_or_else(|| {
TixError::InvalidInput(
"`tix triage` needs a `[triage]` section in tix.toml, and this project has none"
.to_string(),
)
})
}
pub fn render_list(store: &Store, by_age: bool, counts_only: bool) -> Result<String> {
let scope = scope_of(store)?;
let corpus = Corpus::build(store)?;
let today = crate::model::today();
let metrics = inbox::collect(&corpus.issues, &scope, &today);
let mut out = summary_line(&metrics);
out.push('\n');
if counts_only {
return Ok(out);
}
let mut entries: Vec<&QueueEntry<'_>> = metrics.entries.iter().collect();
if by_age {
entries.sort_by(|a, b| match (a.age_days, b.age_days) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
});
}
out.push_str(&row("NUM", "AGE", "TITLE"));
for entry in entries {
out.push_str(&row(
&entry.issue.number.to_string(),
&age_cell(entry.age_days),
&entry.issue.title,
));
}
Ok(out)
}
pub fn render_status(store: &Store) -> Result<String> {
let scope = scope_of(store)?;
let corpus = Corpus::build(store)?;
let today = crate::model::today();
let metrics = inbox::collect(&corpus.issues, &scope, &today);
let mut out = summary_line(&metrics);
out.push('\n');
for (label, count) in [
("awaiting", metrics.awaiting),
("deferred", metrics.deferred),
("accepted", metrics.accepted),
("declined", metrics.declined),
("duplicated", metrics.duplicated),
("unknown age", metrics.unknown_age),
] {
out.push_str(&format!(" {label}: {count}\n"));
}
let distribution: Vec<String> = metrics
.age_distribution
.iter()
.map(|bucket| format!("{} {}", bucket.label, bucket.count))
.collect();
out.push_str(&format!(" age: {}\n", distribution.join(", ")));
Ok(out)
}
fn summary_line(metrics: &QueueMetrics<'_>) -> String {
format!(
"inbox: {} entr{} ({} awaiting, {} deferred)",
metrics.total,
if metrics.total == 1 { "y" } else { "ies" },
metrics.awaiting,
metrics.deferred
)
}
fn row(num: &str, age: &str, title: &str) -> String {
format!("{num:<4} {age:<8} {title}\n")
}
fn age_cell(age: Option<i64>) -> String {
match age {
Some(0) => "today".to_string(),
Some(days) => format!("{days}d"),
None => "unknown".to_string(),
}
}
#[allow(clippy::too_many_arguments)]
fn decide(
store: &Store,
kind: DecisionKind,
selector: &str,
reason: Option<&str>,
actor: Option<&str>,
to: Option<&str>,
of: Option<&str>,
) -> Result<ExitCode> {
let scope = scope_of(store)?;
let issue = store.resolve_issue(selector)?;
let target = match of {
Some(raw) => Some(store.resolve_issue(raw)?),
None => None,
};
let service = DecisionService::new(
&store.config,
scope,
match target.as_ref() {
Some(target) => DuplicateTarget::Resolved(target),
None => DuplicateTarget::Unknown,
},
)?;
let mut request = TriageDecision::new(
kind,
crate::model::utc_now_rfc3339(),
actor
.map(str::to_string)
.unwrap_or_else(crate::storage::actor_identity),
reason.unwrap_or_default(),
);
request.target_status = to.map(str::to_string);
request.target_issue = target.as_ref().map(|target| target.number);
let outcome = match service.apply(&issue, &request) {
Ok(outcome) => outcome,
Err(refusal) => {
println!(
"{}",
ItemOutcome::Rejected(refusal.to_string()).as_line(issue.number, &issue.title)
);
return Ok(ExitCode::UsageConfig);
}
};
match outcome {
DecisionOutcome::NoOp => {
println!("{}", ItemOutcome::NoOp.as_line(issue.number, &issue.title));
Ok(ExitCode::Success)
}
DecisionOutcome::Applied {
issue: updated,
event,
relation,
} => {
let mut written = updated;
if let Some(relation) = relation {
written.relationships.push(relation);
}
let number = written.number;
let title = written.title.clone();
let filename = written.filename.clone();
let filename = store.transaction(|| store.update(&filename, &written))?;
println!(
"{}",
ItemOutcome::Applied { filename }.as_line(number, &title)
);
println!("history: {} ({})", event.kind.as_str(), event.detail);
Ok(ExitCode::Success)
}
}
}
struct BatchFlags<'a> {
select: Option<&'a str>,
filter: Option<&'a str>,
decision: &'a str,
reason: Option<&'a str>,
actor: Option<&'a str>,
of: Option<&'a str>,
to: Option<&'a str>,
apply: bool,
}
fn run_batch(store: &Store, flags: BatchFlags<'_>) -> Result<ExitCode> {
let request = selection_request(flags.select, flags.filter)?;
let kind: DecisionKind = flags.decision.parse()?;
if flags.to.is_some() && kind != DecisionKind::Accept {
return Err(TixError::InvalidInput(format!(
"`--to` names an accept target, and this batch is a {kind} decision"
)));
}
let mut decision = TriageDecision::new(
kind,
crate::model::utc_now_rfc3339(),
flags
.actor
.map(str::to_string)
.unwrap_or_else(crate::storage::actor_identity),
flags.reason.unwrap_or_default(),
);
if kind == DecisionKind::Accept {
decision.target_status = flags.to.map(str::to_string);
}
if decision
.target_status
.as_deref()
.is_some_and(|target| target.trim().is_empty())
{
decision.target_status = None;
}
if decision.target_status.is_none() && kind == DecisionKind::Accept {
decision.target_status = store
.config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone());
}
let selection = bulk::resolve(store, &request)?;
let mut plan = batch::build_plan(store, selection, decision, flags.of)?;
if !flags.apply {
print!("{}", render_plan(&plan));
return Ok(exit_code_for_plan(&plan));
}
plan.confirm();
let report = batch::apply(store, &plan)?;
print!("{}", render_report(&report, &plan));
Ok(exit_code_for_report(&report))
}
fn selection_request(select: Option<&str>, filter: Option<&str>) -> Result<SelectionRequest> {
let select = select.map(str::trim).filter(|raw| !raw.is_empty());
let filter = filter.map(str::trim).filter(|raw| !raw.is_empty());
let (source, query) = match (select, filter) {
(Some(_), None) => (SelectionSource::Explicit, None),
(None, Some(raw)) => (SelectionSource::Filter, Some(raw.to_string())),
(Some(_), Some(_)) => {
return Err(TixError::InvalidInput(
"name exactly one of --select or --filter — they are different grammars, \
so a triage batch will not guess which issues you meant"
.to_string(),
));
}
(None, None) => {
return Err(TixError::InvalidInput(
"name one of --select or --filter so the batch knows which issues to \
decide on"
.to_string(),
));
}
};
let selectors = match select {
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(str::to_string)
.collect(),
None => Vec::new(),
};
if selectors.is_empty() && query.is_none() {
return Err(TixError::InvalidInput(
"the selection names no issue — a selector list like `--select 1,2,3` needs \
at least one selector"
.to_string(),
));
}
Ok(SelectionRequest {
source,
selectors,
query,
scope: SelectionScope::Active,
})
}
pub fn render_plan(plan: &TriagePlan) -> String {
let mut out = render_selection(plan);
out.push_str(&render_decision(plan));
for item in &plan.items {
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;
}
if item.is_noop() {
out.push_str(" no change\n");
continue;
}
for (field, change) in &item.changes {
out.push_str(&format!(" {field}: {}\n", render_change(change)));
}
if let Some(path) = &item.path_change {
out.push_str(&format!(" path: {} -> {}\n", path.from, path.to));
}
for intent in &item.history {
out.push_str(&format!(
" history: {} ({})\n",
intent.kind, intent.detail
));
}
}
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: &TriagePlan) -> String {
let mut out = render_selection(plan);
out.push_str(&render_decision(plan));
let by_number: BTreeMap<u32, &crate::bulk::ItemPlan> =
plan.items.iter().map(|item| (item.number, item)).collect();
for (number, outcome) in &report.outcomes {
let item = by_number.get(number);
let title = item.map(|item| item.title.as_str()).unwrap_or_default();
out.push_str(&outcome.as_line(*number, title));
out.push('\n');
if outcome.is_applied() {
if let Some(change) = item.and_then(|item| item.changes.get("status")) {
out.push_str(&format!(" status: {}\n", render_change(change)));
}
}
}
out.push_str(&format!("{}\n", report.summary()));
out
}
fn render_selection(plan: &TriagePlan) -> 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(|issue| format!(" #{} {}", issue.number, issue.title))
.collect();
if !listing.is_empty() {
out.push_str(&listing.join("\n"));
out.push('\n');
}
out
}
fn render_decision(plan: &TriagePlan) -> String {
let mut out = format!("decision: {}", plan.decision.kind);
if let Some(target) = plan
.decision
.target_status
.as_deref()
.map(str::trim)
.filter(|target| !target.is_empty())
{
out.push_str(&format!(" -> {target}"));
}
if let Some(of) = plan.duplicate_of.as_deref() {
out.push_str(&format!(" of {of}"));
}
out.push('\n');
out
}
fn render_change(change: &FieldChange) -> String {
match change {
FieldChange::Set { from, to } => format!("{from:?} -> {to}"),
FieldChange::Clear { from } => format!("{from:?} -> (empty)"),
FieldChange::NoChange { value } => format!("{value:?} (already in that state)"),
}
}
pub fn exit_code_for_plan(plan: &TriagePlan) -> ExitCode {
if plan.blocked().next().is_some() {
ExitCode::UsageConfig
} else {
ExitCode::Success
}
}
pub fn exit_code_for_report(report: &ApplyReport) -> ExitCode {
if report.rejected() + report.failed() > 0 {
ExitCode::UsageConfig
} else {
ExitCode::Success
}
}