use std::collections::BTreeSet;
use crate::cli::MigrateAction;
use crate::error::{Result, TixError};
use crate::id::{AliasIndex, IssueRef, generate_issue_id, is_issue_id};
use crate::model::Issue;
use crate::storage::Store;
pub fn run(store: &Store, action: &MigrateAction) -> Result<()> {
match action {
MigrateAction::IssueGraph { apply } => issue_graph(store, *apply),
}
}
#[derive(Debug, Clone)]
struct MigrationDiagnostic {
filename: String,
field: String,
reference: IssueRef,
reason: String,
remedy: String,
}
#[derive(Debug, Default)]
struct MigrationPlan {
changes: Vec<Issue>,
diagnostics: Vec<MigrationDiagnostic>,
assigned_ids: usize,
rewritten_parents: usize,
rewritten_relationships: usize,
}
impl MigrationPlan {
fn changed(&self) -> bool {
!self.changes.is_empty()
}
}
fn issue_graph(store: &Store, apply: bool) -> Result<()> {
let plan = build_plan(store)?;
print_plan(&plan, apply);
if !plan.diagnostics.is_empty() {
return Err(TixError::InvalidInput(format!(
"issue-graph migration refused: {} unresolved or ambiguous reference(s); repair them and rerun",
plan.diagnostics.len()
)));
}
if !apply {
return Ok(());
}
if !plan.changed() {
println!("issue-graph migration: no changes");
return Ok(());
}
store.transaction(|| {
for issue in plan.changes {
store.update(&issue.filename, &issue)?;
}
Ok(())
})?;
println!("issue-graph migration applied.");
Ok(())
}
fn build_plan(store: &Store) -> Result<MigrationPlan> {
let original = store.list_all()?;
let mut issues = original.clone();
let mut assigned_ids = 0usize;
let mut used: BTreeSet<String> = issues
.iter()
.filter_map(|issue| issue.id.as_deref())
.filter(|id| is_issue_id(id))
.map(str::to_ascii_uppercase)
.collect();
for issue in &mut issues {
let valid = issue.id.as_deref().and_then(IssueRef::id).is_some();
if valid {
continue;
}
let mut id = generate_issue_id();
while !used.insert(id.clone()) {
id = generate_issue_id();
}
issue.id = Some(id);
assigned_ids += 1;
}
let aliases = AliasIndex::build(&issues);
let mut diagnostics = aliases
.duplicate_ids()
.iter()
.map(|(id, numbers)| MigrationDiagnostic {
filename: aliases
.duplicate_id_files()
.get(id)
.map(|files| files.join(", "))
.unwrap_or_else(|| {
numbers
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
}),
field: "id".to_string(),
reference: IssueRef::id(id).unwrap_or(IssueRef::Number(0)),
reason: format!(
"canonical id `{id}` is claimed by issue aliases {numbers:?} in the files named above"
),
remedy: "give the colliding issues distinct canonical ids by hand".to_string(),
})
.collect::<Vec<_>>();
let mut rewritten_parents = 0usize;
let mut rewritten_relationships = 0usize;
let mut changes = Vec::new();
for (index, issue) in issues.iter_mut().enumerate() {
let mut changed = issue.id != original[index].id;
if let Some(parent) = issue.parent {
match aliases.resolve(&parent) {
Ok(canonical) => {
if canonical != parent {
issue.parent = Some(canonical);
changed = true;
rewritten_parents += 1;
}
}
Err(error) => diagnostics.push(MigrationDiagnostic {
filename: issue.filename.clone(),
field: "parent".to_string(),
reference: parent,
reason: error_message(&error),
remedy: "point `parent:` at one canonical issue id".to_string(),
}),
}
}
for relationship in &mut issue.relationships {
for (field, endpoint) in [
("relationships.from", &mut relationship.from),
("relationships.to", &mut relationship.to),
] {
let original_endpoint = *endpoint;
match aliases.resolve(&original_endpoint) {
Ok(canonical) => {
if canonical != original_endpoint {
*endpoint = canonical;
changed = true;
rewritten_relationships += 1;
}
}
Err(error) => diagnostics.push(MigrationDiagnostic {
filename: issue.filename.clone(),
field: field.to_string(),
reference: original_endpoint,
reason: error_message(&error),
remedy: "replace the endpoint with one canonical issue id".to_string(),
}),
}
}
}
if changed {
changes.push(issue.clone());
}
}
Ok(MigrationPlan {
changes,
diagnostics,
assigned_ids,
rewritten_parents,
rewritten_relationships,
})
}
fn error_message(error: &crate::id::AliasError) -> String {
match error {
crate::id::AliasError::Missing(value) => format!("`{value}` resolves to no issue"),
crate::id::AliasError::Ambiguous { alias, candidates } => format!(
"`{alias}` is ambiguous: {}",
candidates
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
),
crate::id::AliasError::DuplicateId { id, numbers } => {
format!("id `{id}` is claimed by issues {numbers:?}")
}
}
}
fn print_plan(plan: &MigrationPlan, apply: bool) {
println!(
"issue-graph migration: {} file(s) change; {assigned} assigned id(s); {parents} parent endpoint(s); {relationships} relationship endpoint(s)",
plan.changes.len(),
assigned = plan.assigned_ids,
parents = plan.rewritten_parents,
relationships = plan.rewritten_relationships,
);
for issue in &plan.changes {
println!(
" #{} {} -> id {}",
issue.number,
issue.filename,
issue.id.as_deref().unwrap_or("-")
);
}
for diagnostic in &plan.diagnostics {
println!(
" refused {}: {} {}: {}; remedy: {}",
diagnostic.filename,
diagnostic.field,
diagnostic.reference,
diagnostic.reason,
diagnostic.remedy
);
}
if !apply {
println!("dry run; pass --apply to write the plan");
}
}