use std::collections::BTreeMap;
use crate::bulk::{ArchiveRequest, IssuePatch, PatchValue, RelationshipOp, Selection};
use crate::error::Result;
use crate::model::Issue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldChange {
Set { from: String, to: String },
Clear { from: String },
NoChange { value: String },
}
impl FieldChange {
pub fn field(&self) -> &'static str {
match self {
Self::Set { .. } | Self::Clear { .. } | Self::NoChange { .. } => {
unreachable!("the field name is carried by the caller's key")
}
}
}
pub fn writes(&self) -> bool {
matches!(self, Self::Set { .. } | Self::Clear { .. })
}
pub fn as_from_to(&self) -> (String, String) {
match self {
Self::Set { from, to } => (from.clone(), to.clone()),
Self::Clear { from } => (from.clone(), String::new()),
Self::NoChange { value } => (value.clone(), value.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemDiagnostic {
pub issue: u32,
pub field: &'static str,
pub message: String,
}
impl std::fmt::Display for ItemDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}: {}", self.issue, self.message)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryIntent {
pub kind: &'static str,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathChange {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemPlan {
pub number: u32,
pub title: String,
pub changes: BTreeMap<&'static str, FieldChange>,
pub path_change: Option<PathChange>,
pub history: Vec<HistoryIntent>,
pub diagnostic: Option<ItemDiagnostic>,
pub wip: Option<crate::wip::ItemWip>,
}
impl ItemPlan {
pub fn is_actionable(&self) -> bool {
self.diagnostic.is_none()
&& (self.changes.values().any(FieldChange::writes)
|| self.path_change.is_some()
|| !self.history.is_empty())
}
pub fn is_noop(&self) -> bool {
!self.is_actionable() && self.diagnostic.is_none()
}
pub fn status(&self) -> &'static str {
if self.diagnostic.is_some() {
"rejected"
} else if self.is_actionable() {
"applied"
} else {
"no-op"
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchPlan {
pub selection: Selection,
pub patch: IssuePatch,
pub items: Vec<ItemPlan>,
pub pair: Option<PairPlan>,
pub archive: Option<ArchiveRequest>,
pub confirmation: Option<Confirmation>,
}
impl BatchPlan {
pub fn actionable(&self) -> impl Iterator<Item = &ItemPlan> {
self.items.iter().filter(|i| i.is_actionable())
}
pub fn blocked(&self) -> impl Iterator<Item = &ItemPlan> {
self.items.iter().filter(|i| i.diagnostic.is_some())
}
pub fn is_empty(&self) -> bool {
self.pair.is_none() && self.archive.is_none() && self.actionable().next().is_none()
}
pub fn summary(&self) -> String {
let actionable = self.actionable().count();
let blocked = self.blocked().count();
let noop = self.items.iter().filter(|i| i.is_noop()).count();
if !self.patch.has_any_field() {
let mut parts = Vec::new();
if let Some(pair) = &self.pair {
parts.push(format!("1 relationship would {}", pair.verb()));
}
if let Some(archive) = &self.archive {
let count = self.selection.len();
parts.push(format!(
"{count} issue{} would {}",
if count == 1 { "" } else { "s" },
if archive.archive {
"be archived"
} else {
"be restored"
}
));
}
if parts.is_empty() {
parts.push("nothing to do".to_string());
}
return parts.join("; ");
}
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"));
}
if let Some(pair) = &self.pair {
parts.push(format!("1 relationship would {}", pair.verb()));
}
if let Some(archive) = &self.archive {
let count = self.selection.len();
let mut line = format!(
"{count} issue{} would {}",
if count == 1 { "" } else { "s" },
if archive.archive {
"be archived"
} else {
"be restored"
}
);
match (&archive.target_status, archive.problem()) {
(Some(target), _) => line.push_str(&format!(" into '{target}'")),
(None, Some(problem)) => line.push_str(&format!(" — {problem}")),
(None, None) => {}
}
parts.push(line);
}
parts.join("; ")
}
pub fn confirmation_is_valid(&self) -> bool {
match &self.confirmation {
None => false,
Some(confirmation) => confirmation.fingerprint == self.selection.fingerprint(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirmation {
pub fingerprint: Vec<u32>,
}
impl Confirmation {
pub fn issue(plan: &BatchPlan) -> Self {
Self {
fingerprint: plan.selection.fingerprint(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairPlan {
pub link: bool,
pub from: std::result::Result<crate::id::IssueRef, String>,
pub to: std::result::Result<crate::id::IssueRef, String>,
pub edge: Option<crate::relationships::CanonicalEdge>,
pub diagnostic: Option<crate::bulk::pair::PairDiagnostic>,
}
impl PairPlan {
pub fn resolved(link: bool, edge: crate::relationships::CanonicalEdge) -> Self {
Self {
link,
from: Ok(edge.from),
to: Ok(edge.to),
edge: Some(edge),
diagnostic: None,
}
}
pub fn refused(
link: bool,
from: std::result::Result<crate::id::IssueRef, String>,
to: std::result::Result<crate::id::IssueRef, String>,
diagnostic: crate::bulk::pair::PairDiagnostic,
) -> Self {
Self {
link,
from,
to,
edge: None,
diagnostic: Some(diagnostic),
}
}
pub fn is_resolvable(&self) -> bool {
if self.diagnostic.is_some() {
return false;
}
self.from.is_ok() && self.to.is_ok()
}
pub fn verb(&self) -> &'static str {
if self.link { "link" } else { "unlink" }
}
}
pub fn build_plan(
config: &crate::config::Config,
issues_dir: &std::path::Path,
selection: Selection,
patch: IssuePatch,
mut resolve_pair_targets: impl FnMut(&RelationshipOp) -> PairPlan,
) -> Result<BatchPlan> {
patch.require_wellformed()?;
let items: Vec<ItemPlan> = selection
.issues()
.iter()
.map(|issue| {
let diagnostic = first_diagnostic(issue, &patch, config);
let (changes, path_change, history) = propose(issue, &patch, issues_dir);
ItemPlan {
number: issue.number,
title: issue.title.clone(),
changes,
path_change,
history,
diagnostic,
wip: None,
}
})
.collect();
let pair = patch.relationship.as_ref().map(&mut resolve_pair_targets);
let archive = patch.archive.clone();
Ok(BatchPlan {
selection,
patch,
items,
pair,
archive,
confirmation: None,
})
}
fn first_diagnostic(
issue: &Issue,
patch: &IssuePatch,
config: &crate::config::Config,
) -> Option<ItemDiagnostic> {
let numbers = |field: &'static str, message: String| {
Some(ItemDiagnostic {
issue: issue.number,
field,
message,
})
};
if let PatchValue::Set(value) = &patch.priority {
if let Err(e) = crate::commands::parse_priority(&value.to_string()) {
return numbers("priority", e.to_string());
}
}
let start = produced(&patch.start_date, issue.start_date.as_deref());
let due = produced(&patch.due_date, issue.due_date.as_deref());
if let Err(e) = crate::model::validate_issue_dates(start.as_deref(), due.as_deref()) {
return numbers("dates", e.to_string());
}
if let PatchValue::Set(target) = &patch.status {
let (_, resolution) = crate::workflow::active_resolution(config);
match crate::workflow::validate_transition(issue, target, &resolution, config, None) {
crate::workflow::TransitionOutcome::Rejected(diagnostic) => {
return numbers("status", diagnostic.to_string());
}
crate::workflow::TransitionOutcome::Accepted { .. } => {}
}
}
if let PatchValue::Set(value) = &patch.assignee
&& crate::assignee::parse_assignee(value).is_none()
{
return numbers(
"assignee",
format!("assignee {value:?} is not a usable handle or identity"),
);
}
None
}
fn produced(patch: &PatchValue<String>, current: Option<&str>) -> Option<String> {
match patch {
PatchValue::Unchanged => current.map(str::to_string),
PatchValue::Set(v) => Some(v.clone()),
PatchValue::Clear => None,
}
}
fn propose(
issue: &Issue,
patch: &IssuePatch,
issues_dir: &std::path::Path,
) -> (
BTreeMap<&'static str, FieldChange>,
Option<PathChange>,
Vec<HistoryIntent>,
) {
let mut changes: BTreeMap<&'static str, FieldChange> = BTreeMap::new();
let mut path_change = None;
let mut history = Vec::new();
if let PatchValue::Set(target) = &patch.status {
let from = issue.status.display().to_string();
if from == *target {
changes.insert("status", FieldChange::NoChange { value: from });
} else {
changes.insert(
"status",
FieldChange::Set {
from: from.clone(),
to: target.clone(),
},
);
history.push(HistoryIntent {
kind: "transition_accepted",
detail: format!("{from} -> {target}"),
});
}
}
match &patch.priority {
PatchValue::Unchanged => {}
value => record_clearing(
&mut changes,
"priority",
if issue.priority.is_undefined() {
String::new()
} else {
issue.priority.to_string()
},
render_typed(value),
matches!(value, PatchValue::Clear),
),
}
match &patch.estimate {
PatchValue::Unchanged => {}
value => record_clearing(
&mut changes,
"estimate",
issue.estimate.clone().unwrap_or_default(),
render_typed(value),
matches!(value, PatchValue::Clear),
),
}
for (name, value, current) in [
("assignee", &patch.assignee, issue.assignee.clone()),
("start_date", &patch.start_date, issue.start_date.clone()),
("due_date", &patch.due_date, issue.due_date.clone()),
] {
match value {
PatchValue::Unchanged => {}
PatchValue::Set(new) => record(
&mut changes,
name,
current.unwrap_or_default(),
new.clone(),
&mut history,
),
PatchValue::Clear => record_clearing(
&mut changes,
name,
current.unwrap_or_default(),
String::new(),
true,
),
}
}
for (name, value, current) in [
("labels", &patch.labels, Some(&issue.labels[..])),
("cycles", &patch.cycles, issue.cycles.as_deref()),
("milestones", &patch.milestones, issue.milestones.as_deref()),
] {
match value {
PatchValue::Unchanged => {}
PatchValue::Set(new) => record(
&mut changes,
name,
render_list(current.unwrap_or_default()),
render_list(new),
&mut history,
),
PatchValue::Clear => record_clearing(
&mut changes,
name,
render_list(current.unwrap_or_default()),
String::new(),
true,
),
}
}
if let PatchValue::Set(new) = &patch.assignee {
if let Some(segment) = crate::assignee::parse_assignee(new) {
let from = issues_dir.join(crate::assignee::derive_segment(issue.assignee.as_deref()));
let to = issues_dir.join(crate::assignee::derive_segment(Some(&segment)));
if from != to {
path_change = Some(PathChange {
from: from.display().to_string(),
to: to.display().to_string(),
});
}
}
}
(changes, path_change, history)
}
fn record(
changes: &mut BTreeMap<&'static str, FieldChange>,
name: &'static str,
from: String,
to: String,
_history: &mut Vec<HistoryIntent>,
) {
record_clearing(changes, name, from, to, false)
}
fn record_clearing(
changes: &mut BTreeMap<&'static str, FieldChange>,
name: &'static str,
from: String,
to: String,
cleared: bool,
) {
if cleared {
changes.insert(name, FieldChange::Clear { from });
return;
}
if from == to {
changes.insert(name, FieldChange::NoChange { value: to });
return;
}
changes.insert(name, FieldChange::Set { from, to });
}
fn render_typed<T: std::fmt::Display>(value: &PatchValue<T>) -> String {
match value {
PatchValue::Set(v) => v.to_string(),
_ => String::new(),
}
}
fn render_list(values: &[String]) -> String {
values.join(", ")
}