use std::fmt;
use std::str::FromStr;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{HistoryEvent, HistoryEventKind, Issue, Status};
use crate::relationships::{CanonicalEdge, RelationKind, Relationship};
use crate::triage::history::{self, TriageDecisionRecord};
use crate::triage::inbox::InboxScope;
use crate::triage::record_entry;
use crate::workflow::{self, DiagnosticKind, TransitionOutcome, WorkflowDiagnostic};
type DecisionResult<T> = std::result::Result<T, Refusal>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DecisionKind {
Accept,
Decline,
Duplicate,
Defer,
}
impl DecisionKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
DecisionKind::Accept => "accept",
DecisionKind::Decline => "decline",
DecisionKind::Duplicate => "duplicate",
DecisionKind::Defer => "defer",
}
}
#[must_use]
pub fn event_kind(self) -> HistoryEventKind {
match self {
DecisionKind::Accept => HistoryEventKind::TriageAccepted,
DecisionKind::Decline => HistoryEventKind::TriageDeclined,
DecisionKind::Duplicate => HistoryEventKind::TriageDuplicated,
DecisionKind::Defer => HistoryEventKind::TriageDeferred,
}
}
#[must_use]
pub fn requires_reason(self) -> bool {
!matches!(self, DecisionKind::Accept)
}
}
impl fmt::Display for DecisionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for DecisionKind {
type Err = TixError;
fn from_str(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"accept" => Ok(DecisionKind::Accept),
"decline" => Ok(DecisionKind::Decline),
"duplicate" => Ok(DecisionKind::Duplicate),
"defer" => Ok(DecisionKind::Defer),
other => Err(TixError::InvalidInput(format!(
"unknown triage decision `{other}` — expected accept, decline, \
duplicate, or defer"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageDecision {
pub kind: DecisionKind,
pub reason: String,
pub actor: String,
pub target_issue: Option<u32>,
pub target_status: Option<String>,
pub timestamp: String,
}
impl TriageDecision {
#[must_use]
pub fn new(
kind: DecisionKind,
timestamp: impl Into<String>,
actor: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
kind,
reason: reason.into(),
actor: actor.into(),
target_issue: None,
target_status: None,
timestamp: timestamp.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DuplicateTarget<'a> {
Resolved(&'a Issue),
Unknown,
Ambiguous,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Plan {
Move(String),
Duplicate {
target_number: u32,
target_title: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
kind: &'static str,
message: String,
}
impl Refusal {
#[must_use]
pub fn new(kind: &'static str, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
fn missing_reason(issue: u32, kind: DecisionKind) -> Self {
Self::new(
"missing_reason",
format!("`#{issue}` {kind} requires a reason"),
)
}
fn missing_actor(issue: u32) -> Self {
Self::new(
"missing_actor",
format!(
"`#{issue}` needs an actor: a triage decision is never recorded \
anonymously"
),
)
}
fn missing_target(issue: u32) -> Self {
Self::new(
"missing_target",
format!(
"`#{issue}` accept has no target column: pass `--to`, or set \
`[triage].accepted_status`"
),
)
}
fn invalid_duplicate_target(message: impl Into<String>) -> Self {
Self::new("invalid_duplicate_target", message)
}
fn unknown_issue(message: impl Into<String>) -> Self {
Self::new("unknown_issue", message)
}
fn ambiguous() -> Self {
Self::invalid_duplicate_target(
"`--of` matched more than one issue; name the duplicate target by \
its number",
)
}
fn not_in_triage(issue: u32) -> Self {
Self::new(
"not_in_triage",
format!(
"`#{issue}` is not in the triage inbox: decisions are made from \
the queue, and this issue is in a normal column"
),
)
}
fn already_decided(issue: u32) -> Self {
Self::new(
"already_decided",
format!(
"`#{issue}` already carries a triage decision and has left the \
inbox; change it with a normal status command instead"
),
)
}
fn from_workflow(diagnostic: &WorkflowDiagnostic) -> Self {
let kind = match diagnostic.kind {
DiagnosticKind::UnknownColumn => "unknown_column",
_ => "invalid_transition",
};
Self::new(
kind,
format!("{}: {}", diagnostic.subject, diagnostic.message),
)
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.message.starts_with(self.kind) {
f.write_str(&self.message)
} else {
write!(f, "{}: {}", self.kind, self.message)
}
}
}
impl From<Refusal> for TixError {
fn from(refusal: Refusal) -> Self {
TixError::Refused(crate::error::render_diagnostics(
"E-tix-triage",
[(refusal.kind, refusal.message.as_str())],
))
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecisionOutcome {
Applied {
issue: Issue,
event: HistoryEvent,
relation: Option<Relationship>,
},
NoOp,
}
impl DecisionOutcome {
#[must_use]
pub fn is_noop(&self) -> bool {
matches!(self, DecisionOutcome::NoOp)
}
}
pub struct DecisionService<'a> {
config: &'a Config,
scope: InboxScope,
accepted_status: Option<String>,
declined_status: String,
target: DuplicateTarget<'a>,
}
impl<'a> DecisionService<'a> {
pub fn new(
config: &'a Config,
scope: InboxScope,
target: DuplicateTarget<'a>,
) -> DecisionResult<Self> {
let declined_status = config
.triage
.as_ref()
.and_then(|triage| triage.declined_status.clone())
.ok_or_else(|| {
Refusal::new(
"unknown_column",
"`[triage].declined_status` names no column, so a decline \
would have nowhere to go",
)
})?;
let accepted_status = config
.triage
.as_ref()
.and_then(|t| t.accepted_status.clone());
Ok(Self {
config,
scope,
accepted_status,
declined_status,
target,
})
}
pub fn apply(
&self,
issue: &Issue,
request: &TriageDecision,
) -> DecisionResult<DecisionOutcome> {
check_required(issue, request)?;
let plan = self.plan(issue, request)?;
if is_noop(issue, request, &plan) {
return Ok(DecisionOutcome::NoOp);
}
if !self.scope.matches(issue) {
return Err(if history::has_triage_decision(issue) {
Refusal::already_decided(issue.number)
} else {
Refusal::not_in_triage(issue.number)
});
}
let prior_state = issue.status.display().to_string();
let mut updated = issue.clone();
let (result, relation) = match plan {
Plan::Move(column) => {
let outcome = workflow::validate_transition(
issue,
&column,
&self.scope.resolution,
self.config,
None,
);
let to = match outcome {
TransitionOutcome::Accepted { to, .. } => to,
TransitionOutcome::Rejected(diagnostic) => {
return Err(Refusal::from_workflow(&diagnostic));
}
};
updated.status = Status::new(&to);
(to, None)
}
Plan::Duplicate {
target_number,
target_title,
} => {
let relation = Relationship::new(
issue.number,
target_number,
RelationKind::Duplicates,
&target_title,
);
let result = format!("{} #{}", relation.kind, relation.to);
(result, Some(relation))
}
};
match request.kind {
DecisionKind::Defer => {
record_entry(&mut updated, &request.timestamp, Some("defer"));
}
DecisionKind::Accept | DecisionKind::Decline if !self.scope.matches(&updated) => {
updated.entered_at = None;
updated.source = None;
}
_ => {}
}
let record = TriageDecisionRecord::new(
&request.timestamp,
&request.actor,
request.kind.event_kind(),
prior_state,
result,
Some(request.reason.clone()),
request.target_issue,
crate::triage::entry_source(&updated).map(str::to_string),
);
let event = record.to_event();
updated.history.append(event.clone());
Ok(DecisionOutcome::Applied {
issue: updated,
event,
relation,
})
}
fn plan(&self, issue: &Issue, request: &TriageDecision) -> DecisionResult<Plan> {
match request.kind {
DecisionKind::Accept => {
let named = request
.target_status
.as_deref()
.map(str::trim)
.filter(|target| !target.is_empty())
.map(str::to_string);
named
.or_else(|| self.accepted_status.clone())
.map(Plan::Move)
.ok_or_else(|| Refusal::missing_target(issue.number))
}
DecisionKind::Decline => Ok(Plan::Move(self.declined_status.clone())),
DecisionKind::Defer => Ok(Plan::Move(self.scope.deferred_status.clone())),
DecisionKind::Duplicate => {
let target = self.resolve_duplicate(issue, request)?;
Ok(Plan::Duplicate {
target_number: target.number,
target_title: target.title.clone(),
})
}
}
}
fn resolve_duplicate(
&self,
issue: &Issue,
request: &TriageDecision,
) -> DecisionResult<&'a Issue> {
if let DuplicateTarget::Ambiguous = self.target {
return Err(Refusal::ambiguous());
}
let asked = request.target_issue.ok_or_else(|| {
Refusal::invalid_duplicate_target(format!(
"`#{}` is a duplicate decision with no target (`--of`)",
issue.number
))
})?;
if CanonicalEdge::new(issue.number, asked, RelationKind::Duplicates).is_self_link() {
return Err(Refusal::invalid_duplicate_target(format!(
"`#{asked}` cannot be a duplicate of itself"
)));
}
match self.target {
DuplicateTarget::Resolved(target) if target.number == asked => Ok(target),
DuplicateTarget::Resolved(target) => Err(Refusal::unknown_issue(format!(
"`--of` resolved to `#{}`, but `#{asked}` was asked for",
target.number
))),
DuplicateTarget::Unknown => {
Err(Refusal::unknown_issue(format!("`#{asked}` does not exist")))
}
DuplicateTarget::Ambiguous => Err(Refusal::ambiguous()),
}
}
}
fn is_noop(issue: &Issue, request: &TriageDecision, plan: &Plan) -> bool {
match plan {
Plan::Duplicate { target_number, .. } => {
let wanted = CanonicalEdge::new(issue.number, *target_number, RelationKind::Duplicates);
issue
.relationships
.iter()
.any(|relation| relation.canonical() == wanted)
}
Plan::Move(column) => {
if !issue.status.is(column) {
return false;
}
history::last_decision(issue).is_some_and(|record| {
record.is_same_decision(request.kind.event_kind(), Some(request.reason.as_str()))
})
}
}
}
fn check_required(issue: &Issue, request: &TriageDecision) -> DecisionResult<()> {
if request.kind.requires_reason() && request.reason.trim().is_empty() {
return Err(Refusal::missing_reason(issue.number, request.kind));
}
if request.actor.trim().is_empty() {
return Err(Refusal::missing_actor(issue.number));
}
if request.reason.chars().any(is_line_break) {
return Err(Refusal::new(
"invalid_reason",
format!(
"`#{}` {} has a line break in its reason; a reason is written to one \
History line, so a line break would look like a second decision",
issue.number, request.kind
),
));
}
if request.actor.chars().any(is_line_break) {
return Err(Refusal::new(
"invalid_actor",
format!(
"`#{}` has a line break in its actor; the actor is written to one History \
line, so a line break would look like a second decision",
issue.number
),
));
}
Ok(())
}
pub fn is_line_break(c: char) -> bool {
matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}')
}