#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) fn triage_list(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let by_age = match opt_bool(args, "age") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
let counts_only = match opt_bool(args, "count") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_list(store, by_age, counts_only) {
Ok(text) => text,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
let mut rows: Vec<&inbox::QueueEntry<'_>> = metrics.entries.iter().collect();
if by_age {
rows.sort_by(|a, b| match (a.age_days, b.age_days) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
let structured = json!({
"counts": triage_counts_value(&metrics),
"entries": if counts_only {
json!([])
} else {
triage_entries_value(&rows)
},
});
Ok(success_result(text, structured))
}
pub(super) fn triage_run(config: &Config, store: &Store, args: &Value, apply: bool) -> ToolOutcome {
if apply {
match opt_bool(args, "confirm") {
Ok(Some(true)) => {}
Ok(_) => {
return Ok(error_result(
"confirm is required and must be true — a triage apply writes files \
and appends History, so nothing is written without it (C-004). Call \
triage_preview first; it is read-only."
.to_string(),
));
}
Err(()) => return Err(()),
}
}
let raw = match req_string(args, "decision") {
Ok(raw) => raw,
Err(()) => return Err(()),
};
let kind = match raw.trim().parse::<DecisionKind>() {
Ok(kind) => kind,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let reason = match opt_string(args, "reason") {
Ok(value) => value.unwrap_or_default(),
Err(()) => return Err(()),
};
let actor = match opt_string(args, "actor") {
Ok(value) => value.unwrap_or_else(crate::storage::actor_identity),
Err(()) => return Err(()),
};
let mut request = TriageDecision::new(kind, crate::model::utc_now_rfc3339(), actor, reason);
let to = match opt_string(args, "to") {
Ok(value) => value,
Err(()) => return Err(()),
};
if let Some(target) = to.as_deref() {
if kind != DecisionKind::Accept {
return Ok(error_result(format!(
"`to` names an accept target, and this decision is a {kind} — a {kind} \
decision has no target column, so `to: \"{target}\"` would be silently \
ignored. Name the target on an accept, and use `of` for a duplicate."
)));
}
}
if kind == DecisionKind::Accept {
request.target_status = to.filter(|t| !t.trim().is_empty());
if request.target_status.is_none() {
request.target_status = config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone());
}
}
let of = match opt_string(args, "of") {
Ok(value) => value,
Err(()) => return Err(()),
};
let selection = match triage_selection(store, args) {
Ok(selection) => selection,
Err(outcome) => return outcome,
};
let plan = match batch::build_plan(store, selection, request, of.as_deref()) {
Ok(plan) => plan,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
if !apply {
return Ok(success_result(
cmd_triage::render_plan(&plan),
triage_plan_value(&plan, false),
));
}
let mut confirmed = plan.clone();
confirmed.confirm();
let report = match batch::apply(store, &confirmed) {
Ok(report) => report,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let text = cmd_triage::render_report(&report, &confirmed);
let structured = triage_report_value(&report, &confirmed);
Ok(success_result(text, structured))
}
pub(super) fn triage_status(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_status(store) {
Ok(text) => text,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
Ok(success_result(
text,
json!({ "counts": triage_counts_value(&metrics) }),
))
}
pub(super) fn dispatch(
config: &Config,
store: &Store,
name: &str,
arguments: &Value,
) -> Option<ToolOutcome> {
match name {
"triage_list" => Some(triage_list(config, store, arguments)),
"triage_status" => Some(triage_status(config, store, arguments)),
"triage_preview" => Some(triage_run(config, store, arguments, false)),
"triage_apply" => Some(triage_run(config, store, arguments, true)),
_ => None,
}
}
pub(super) fn triage_scope(config: &Config) -> std::result::Result<inbox::InboxScope, ToolOutcome> {
match inbox::scope(config) {
Ok(Some(scope)) => Ok(scope),
Ok(None) => Err(Ok(error_result(
crate::error::TixError::InvalidInput(NO_INBOX.to_string()).to_string(),
))),
Err(e) => Err(Ok(error_result(crate::error::render_error(&e)))),
}
}
pub(super) fn triage_counts_value(metrics: &inbox::QueueMetrics<'_>) -> Value {
json!({
"total": metrics.total,
"awaiting": metrics.awaiting,
"deferred": metrics.deferred,
"accepted": metrics.accepted,
"declined": metrics.declined,
"duplicated": metrics.duplicated,
"unknown_age": metrics.unknown_age,
"age": metrics.age_distribution.iter()
.map(|bucket| json!({ "bucket": bucket.label, "count": bucket.count }))
.collect::<Vec<_>>(),
})
}
pub(super) fn triage_entries_value(entries: &[&inbox::QueueEntry<'_>]) -> Value {
Value::Array(
entries
.iter()
.map(|entry| {
let issue = entry.issue;
json!({
"number": issue.number,
"title": issue.title,
"status": issue.status.display(),
"age_days": entry.age_days,
"entered_at": match entry.entered_at {
crate::triage::EntryTime::Recorded(at) => json!(at),
crate::triage::EntryTime::Unknown => Value::Null,
},
"source": crate::triage::entry_source(issue),
"last_decision": match crate::triage::history::last_decision_state(issue) {
crate::triage::history::LastDecision::Readable(record) => json!({
"kind": record.kind.as_str(),
"prior_state": record.prior_state,
"result": record.result,
"reason": record.reason,
"actor": record.actor,
"timestamp": record.timestamp,
"target_issue": record.target_issue,
}),
crate::triage::history::LastDecision::Unreadable => json!("unreadable"),
crate::triage::history::LastDecision::None => Value::Null,
},
})
})
.collect(),
)
}
pub(super) fn triage_selection(store: &Store, args: &Value) -> ToolOutcome2<bulk::Selection> {
let one = opt_selector(args, "issue")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let select = opt_string(args, "select")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let filter = opt_string(args, "filter")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let named =
usize::from(one.is_some()) + usize::from(select.is_some()) + usize::from(filter.is_some());
if named == 0 {
return Err(refuse(
"name the issues to decide: `issue` for one issue, or `select`/`filter` for \
a selection. A decision with no issue in it has nothing to report on."
.to_string(),
));
}
if named > 1 {
return Err(refuse(
"name exactly one of `issue`, `select` or `filter` — they are three grammars \
for the same selection, and this tool does not guess which you meant"
.to_string(),
));
}
let mut selectors: Vec<String> = select.into_iter().collect();
if let Some(one) = one {
selectors.push(one);
}
let request = cmd_bulk::SelectionRequest::new(
&selectors,
filter.as_deref(),
None,
bulk::SelectionScope::Active,
)
.map_err(|e| refuse(e.to_string()))?;
cmd_bulk::resolve(store, &request).map_err(|e| refuse(e.to_string()))
}
pub(super) fn triage_plan_value(plan: &TriagePlan, applied: bool) -> Value {
json!({
"applied": applied,
"decision": plan.decision.kind.as_str(),
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"items": plan.items.iter().map(|item| json!({
"number": item.number,
"title": item.title,
"status": item.status(),
"changes": item.changes.iter()
.map(|(field, change)| {
let (from, to) = change.as_from_to();
json!({ "field": field, "from": from, "to": to })
})
.collect::<Vec<_>>(),
"history": item.history.iter()
.map(|h| json!({ "kind": h.kind, "detail": h.detail }))
.collect::<Vec<_>>(),
"diagnostic": item.diagnostic.as_ref().map(|d| json!({
"issue": d.issue,
"field": d.field,
"message": d.message,
})),
})).collect::<Vec<_>>(),
"summary": plan.summary(),
})
}
pub(super) fn triage_report_value(report: &bulk::ApplyReport, plan: &TriagePlan) -> Value {
let titles: Vec<(u32, String)> = plan
.items
.iter()
.map(|i| (i.number, i.title.clone()))
.collect();
json!({
"applied": report.applied(),
"no_op": report.noop(),
"rejected": report.rejected(),
"failed": report.failed(),
"outcomes": report.outcomes.iter().map(|(number, outcome)| {
let title = titles.iter().find(|(n, _)| n == number)
.map(|(_, t)| t.as_str()).unwrap_or("");
json!({
"number": number,
"title": title,
"status": outcome.status(),
"line": outcome.as_line(*number, title),
})
}).collect::<Vec<_>>(),
})
}