use std::collections::BTreeMap;
use crate::bulk::plan::ItemDiagnostic;
use crate::bulk::{
ApplyReport, Confirmation, FieldChange, HistoryIntent, ItemOutcome, ItemPlan, Selection,
};
use crate::error::{Result, TixError, render_diagnostics_plain};
use crate::model::Issue;
use crate::relationships::{IssueView, Relationship, RelationshipGraph};
use crate::storage::{IssueSelector, Store};
use crate::triage::decision::{
DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, TriageDecision,
};
use crate::triage::inbox;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriagePlan {
pub selection: Selection,
pub decision: TriageDecision,
pub duplicate_of: Option<String>,
pub items: Vec<ItemPlan>,
pub confirmation: Option<Confirmation>,
}
impl TriagePlan {
pub fn actionable(&self) -> impl Iterator<Item = &ItemPlan> {
self.items.iter().filter(|item| item.is_actionable())
}
pub fn blocked(&self) -> impl Iterator<Item = &ItemPlan> {
self.items.iter().filter(|item| item.diagnostic.is_some())
}
pub fn summary(&self) -> String {
let actionable = self.actionable().count();
let blocked = self.blocked().count();
let noop = self.items.iter().filter(|item| item.is_noop()).count();
let mut parts = vec![format!("{actionable} of {} would change", self.items.len())];
if noop > 0 {
parts.push(format!("{noop} already in that state"));
}
if blocked > 0 {
parts.push(format!("{blocked} refused"));
}
parts.join("; ")
}
pub fn confirm(&mut self) {
let fingerprint = self.selection.fingerprint();
self.confirmation = Some(Confirmation { fingerprint });
}
pub fn confirmation_is_valid(&self) -> bool {
match &self.confirmation {
None => false,
Some(confirmation) => confirmation.fingerprint == self.selection.fingerprint(),
}
}
}
pub fn build_plan(
store: &Store,
selection: Selection,
mut decision: TriageDecision,
duplicate_of: Option<&str>,
) -> Result<TriagePlan> {
if decision.kind != DecisionKind::Duplicate && duplicate_of.is_some() {
return Err(TixError::InvalidInput(format!(
"a duplicate target was named, and this decision is a {} — only a \
duplicate decision takes one",
decision.kind
)));
}
let target = duplicate_of
.map(|raw| store.resolve_issue(raw))
.transpose()?;
decision.target_issue = target.as_ref().map(|issue| issue.number);
let service = service(store, target.as_ref())?;
let repo = store.list()?;
let views: Vec<IssueView<'_>> = repo.iter().map(Issue::issue_view).collect();
let done_column = store.relationship_done_column();
let graph = RelationshipGraph::new(&views, &done_column);
let items = selection
.issues()
.iter()
.map(|issue| plan_item(&service, &graph, issue, &decision))
.collect();
Ok(TriagePlan {
selection,
decision,
duplicate_of: duplicate_of.map(str::to_string),
items,
confirmation: None,
})
}
pub fn apply(store: &Store, plan: &TriagePlan) -> Result<ApplyReport> {
if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
return Err(TixError::InvalidInput(
"a triage apply needs a confirmation for exactly this plan — preview it \
first, and preview again if the selection or the decision changed"
.to_string(),
));
}
let target = plan
.duplicate_of
.as_deref()
.map(|raw| store.resolve_issue(raw))
.transpose()?;
let mut decision = plan.decision.clone();
decision.target_issue = target.as_ref().map(|issue| issue.number);
let service = service(store, target.as_ref())?;
let repo = store.list()?;
let views: Vec<IssueView<'_>> = repo.iter().map(Issue::issue_view).collect();
let done_column = store.relationship_done_column();
let graph = RelationshipGraph::new(&views, &done_column);
let mut outcomes = Vec::with_capacity(plan.items.len());
let mut write_failures = Vec::new();
for item in &plan.items {
if let Some(diagnostic) = &item.diagnostic {
outcomes.push((
item.number,
ItemOutcome::Rejected(diagnostic.message.clone()),
));
continue;
}
let number = item.number;
match apply_one(store, &service, &graph, number, &decision) {
Ok(outcome) => outcomes.push((number, outcome)),
Err((diagnostic, error)) => {
write_failures.push(diagnostic.clone());
outcomes.push((
number,
ItemOutcome::Failed {
item: diagnostic,
error,
},
));
}
}
}
Ok(ApplyReport {
outcomes,
write_failures,
})
}
fn plan_item(
service: &DecisionService<'_>,
graph: &RelationshipGraph<'_>,
issue: &Issue,
decision: &TriageDecision,
) -> ItemPlan {
let outcome = match service.apply(issue, decision) {
Ok(outcome) => outcome,
Err(refusal) => return rejected(issue, "decision", refusal.to_string()),
};
let DecisionOutcome::Applied {
issue: updated,
event,
relation,
} = outcome
else {
return empty_item(issue);
};
if let Some(relation) = relation.as_ref() {
if let Some(message) = blocking(graph, relation) {
return rejected(issue, "relationship", message);
}
}
ItemPlan {
number: issue.number,
title: issue.title.clone(),
changes: diff(issue, &updated),
path_change: None,
history: vec![HistoryIntent {
kind: event.kind.as_str(),
detail: event.detail.clone(),
}],
diagnostic: None,
wip: None,
}
}
fn empty_item(issue: &Issue) -> ItemPlan {
ItemPlan {
number: issue.number,
title: issue.title.clone(),
changes: BTreeMap::new(),
path_change: None,
history: Vec::new(),
diagnostic: None,
wip: None,
}
}
fn rejected(issue: &Issue, field: &'static str, message: String) -> ItemPlan {
ItemPlan {
number: issue.number,
title: issue.title.clone(),
changes: BTreeMap::new(),
path_change: None,
history: Vec::new(),
diagnostic: Some(ItemDiagnostic {
issue: issue.number,
field,
message,
}),
wip: None,
}
}
fn diff(from: &Issue, to: &Issue) -> BTreeMap<&'static str, FieldChange> {
let mut changes = BTreeMap::new();
if from.status.display() != to.status.display() {
changes.insert(
"status",
FieldChange::Set {
from: from.status.display().to_string(),
to: to.status.display().to_string(),
},
);
}
optional(
&mut changes,
"entered_at",
from.entered_at.as_deref(),
to.entered_at.as_deref(),
);
optional(
&mut changes,
"source",
from.source.as_deref(),
to.source.as_deref(),
);
changes
}
fn optional(
changes: &mut BTreeMap<&'static str, FieldChange>,
field: &'static str,
from: Option<&str>,
to: Option<&str>,
) {
if from == to {
return;
}
match (from, to) {
(Some(older), None) => {
changes.insert(
field,
FieldChange::Clear {
from: older.to_string(),
},
);
}
(older, newer) => {
changes.insert(
field,
FieldChange::Set {
from: older.unwrap_or_default().to_string(),
to: newer.unwrap_or_default().to_string(),
},
);
}
}
}
fn blocking(graph: &RelationshipGraph<'_>, relation: &Relationship) -> Option<String> {
let verdict = graph.validate_create(relation.from, relation.to, relation.kind);
if !verdict.blocking() {
return None;
}
Some(render_diagnostics_plain(
verdict
.errors
.iter()
.map(|d| (d.kind.as_str(), d.message.as_str())),
))
}
#[allow(clippy::type_complexity)]
fn apply_one(
store: &Store,
service: &DecisionService<'_>,
graph: &RelationshipGraph<'_>,
number: u32,
decision: &TriageDecision,
) -> std::result::Result<ItemOutcome, (ItemDiagnostic, String)> {
let issue = store
.get(IssueSelector::Number(number))
.map_err(|e| failed(number, "read", e.to_string()))?;
let outcome = match service.apply(&issue, decision) {
Ok(outcome) => outcome,
Err(refusal) => return Ok(ItemOutcome::Rejected(refusal.to_string())),
};
let DecisionOutcome::Applied {
issue: updated,
event: _,
relation,
} = outcome
else {
return Ok(ItemOutcome::NoOp);
};
let mut written = updated;
if let Some(relation) = relation {
if let Some(message) = blocking(graph, &relation) {
return Ok(ItemOutcome::Rejected(message));
}
written.relationships.push(relation);
}
let filename = written.filename.clone();
let filename = store
.transaction(|| store.update(&filename, &written))
.map_err(|e| failed(number, "write", format!("write_failed: {e}")))?;
Ok(ItemOutcome::Applied { filename })
}
#[allow(clippy::type_complexity)]
fn failed(number: u32, field: &'static str, message: String) -> (ItemDiagnostic, String) {
(
ItemDiagnostic {
issue: number,
field,
message: message.clone(),
},
message,
)
}
fn service<'a>(store: &'a Store, target: Option<&'a Issue>) -> Result<DecisionService<'a>> {
let scope = inbox::scope(&store.config)?.ok_or_else(|| {
TixError::InvalidInput(
"a triage batch needs a `[triage]` section in tix.toml, and this project \
has none"
.to_string(),
)
})?;
Ok(DecisionService::new(
&store.config,
scope,
match target {
Some(issue) => DuplicateTarget::Resolved(issue),
None => DuplicateTarget::Unknown,
},
)?)
}