use serde::Serialize;
use crate::error::{Error, Result};
use super::plan::{ReconcileAction, ReconcilePlan, ReconcilePlanCounts};
use super::tracker::{CloseReason, Tracker};
pub fn apply_plan(plan: ReconcilePlan, tracker: &dyn Tracker) -> Result<ReconcileResult> {
let mut executions: Vec<ReconcileExecution> = Vec::with_capacity(plan.actions.len());
for action in &plan.actions {
let exec = execute_action(action, tracker);
executions.push(exec);
}
let counts = plan.counts();
let failed_count = executions
.iter()
.filter(|e| matches!(e.outcome, ExecutionOutcome::Failed { .. }))
.count();
Ok(ReconcileResult {
plan,
executions,
counts,
failed_count,
})
}
fn execute_action(action: &ReconcileAction, tracker: &dyn Tracker) -> ReconcileExecution {
let summary = summary_for(action);
let outcome = match action {
ReconcileAction::FileNew {
title,
body,
labels,
..
} => match tracker.create_issue(title, body, labels) {
Ok(number) => ExecutionOutcome::Filed { number },
Err(e) => ExecutionOutcome::failed(&e),
},
ReconcileAction::Update {
number,
title,
body,
..
} => match tracker.update_issue(*number, Some(title), Some(body)) {
Ok(()) => ExecutionOutcome::Updated { number: *number },
Err(e) => ExecutionOutcome::failed(&e),
},
ReconcileAction::UpdateClosed { number, body, .. } => {
match tracker.update_issue(*number, None, Some(body)) {
Ok(()) => ExecutionOutcome::UpdatedClosed { number: *number },
Err(e) => ExecutionOutcome::failed(&e),
}
}
ReconcileAction::Close {
number, comment, ..
} => match tracker.close_issue(*number, CloseReason::Completed, Some(comment)) {
Ok(()) => ExecutionOutcome::Closed { number: *number },
Err(e) => ExecutionOutcome::failed(&e),
},
ReconcileAction::CloseDuplicate {
number,
keep,
comment,
..
} => match tracker.close_issue(*number, CloseReason::NotPlanned, Some(comment)) {
Ok(()) => ExecutionOutcome::ClosedDuplicate {
number: *number,
keep: *keep,
},
Err(e) => ExecutionOutcome::failed(&e),
},
ReconcileAction::Skip { .. } => ExecutionOutcome::Skipped,
};
ReconcileExecution { summary, outcome }
}
fn summary_for(action: &ReconcileAction) -> String {
match action {
ReconcileAction::FileNew {
command,
component_id,
category,
count,
..
} => format!(
"file_new {}: {} in {} ({})",
command, category, component_id, count
),
ReconcileAction::Update {
number,
category,
count,
..
} => format!("update {} ({} → #{})", category, count, number),
ReconcileAction::UpdateClosed {
number,
category,
count,
..
} => format!(
"update_closed {} ({} → #{}) [stays closed]",
category, count, number
),
ReconcileAction::Close {
number, category, ..
} => format!("close {} → #{}", category, number),
ReconcileAction::CloseDuplicate {
number,
keep,
category,
..
} => format!(
"dedupe {} → keep #{} close #{}",
category, keep, number
),
ReconcileAction::Skip {
category, reason, ..
} => format!("skip {} ({:?})", category, reason),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReconcileExecution {
pub summary: String,
pub outcome: ExecutionOutcome,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum ExecutionOutcome {
Filed { number: u64 },
Updated { number: u64 },
UpdatedClosed { number: u64 },
Closed { number: u64 },
ClosedDuplicate { number: u64, keep: u64 },
Skipped,
Failed { error: String },
}
impl ExecutionOutcome {
fn failed(err: &Error) -> Self {
ExecutionOutcome::Failed {
error: err.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReconcileResult {
pub plan: ReconcilePlan,
pub executions: Vec<ReconcileExecution>,
pub counts: ReconcilePlanCounts,
pub failed_count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::issues::plan::{ReconcileAction, ReconcileSkipReason};
use std::cell::RefCell;
struct MockTracker {
calls: RefCell<Vec<String>>,
fail_create: bool,
fail_close: bool,
}
impl MockTracker {
fn new() -> Self {
Self {
calls: RefCell::new(Vec::new()),
fail_create: false,
fail_close: false,
}
}
}
impl Tracker for MockTracker {
fn list_issues(
&self,
_label: &str,
_limit: usize,
) -> Result<Vec<crate::core::issues::TrackedIssue>> {
unimplemented!("apply_plan does not call list_issues")
}
fn create_issue(&self, title: &str, _body: &str, _labels: &[String]) -> Result<u64> {
self.calls.borrow_mut().push(format!("create:{}", title));
if self.fail_create {
Err(Error::internal_io("create failed", None))
} else {
Ok(42)
}
}
fn update_issue(
&self,
number: u64,
title: Option<&str>,
_body: Option<&str>,
) -> Result<()> {
self.calls
.borrow_mut()
.push(format!("update:#{}:{}", number, title.unwrap_or("-")));
Ok(())
}
fn close_issue(
&self,
number: u64,
reason: CloseReason,
_comment: Option<&str>,
) -> Result<()> {
self.calls
.borrow_mut()
.push(format!("close:#{}:{:?}", number, reason));
if self.fail_close {
Err(Error::internal_io("close failed", None))
} else {
Ok(())
}
}
}
fn file_new(category: &str) -> ReconcileAction {
ReconcileAction::FileNew {
command: "audit".into(),
component_id: "c".into(),
category: category.into(),
title: format!("audit: {} in c (5)", category),
body: "body".into(),
labels: vec!["audit".into()],
count: 5,
}
}
#[test]
fn applies_file_new_via_tracker() {
let plan = ReconcilePlan {
actions: vec![file_new("x")],
};
let tracker = MockTracker::new();
let result = apply_plan(plan, &tracker).unwrap();
assert_eq!(result.executions.len(), 1);
assert!(matches!(
result.executions[0].outcome,
ExecutionOutcome::Filed { number: 42 }
));
assert_eq!(result.failed_count, 0);
assert_eq!(tracker.calls.borrow().len(), 1);
}
#[test]
fn applies_update_close_dedupe_in_order() {
let plan = ReconcilePlan {
actions: vec![
ReconcileAction::Update {
number: 100,
title: "audit: x in c (3)".into(),
body: "b".into(),
category: "x".into(),
count: 3,
},
ReconcileAction::Close {
number: 200,
category: "y".into(),
comment: "resolved".into(),
},
ReconcileAction::CloseDuplicate {
number: 300,
keep: 100,
category: "x".into(),
comment: "dupe of #100".into(),
},
],
};
let tracker = MockTracker::new();
let result = apply_plan(plan, &tracker).unwrap();
assert_eq!(result.executions.len(), 3);
assert!(matches!(
result.executions[0].outcome,
ExecutionOutcome::Updated { number: 100 }
));
assert!(matches!(
result.executions[1].outcome,
ExecutionOutcome::Closed { number: 200 }
));
assert!(matches!(
result.executions[2].outcome,
ExecutionOutcome::ClosedDuplicate {
number: 300,
keep: 100,
}
));
let calls = tracker.calls.borrow();
assert_eq!(calls[0], "update:#100:audit: x in c (3)");
assert_eq!(calls[1], "close:#200:Completed");
assert_eq!(calls[2], "close:#300:NotPlanned");
}
#[test]
fn skip_actions_make_no_tracker_calls() {
let plan = ReconcilePlan {
actions: vec![
ReconcileAction::Skip {
category: "x".into(),
component_id: "c".into(),
reason: ReconcileSkipReason::SuppressedByConfig,
},
ReconcileAction::Skip {
category: "y".into(),
component_id: "c".into(),
reason: ReconcileSkipReason::SuppressedByLabel,
},
],
};
let tracker = MockTracker::new();
let result = apply_plan(plan, &tracker).unwrap();
assert!(tracker.calls.borrow().is_empty());
assert!(matches!(
result.executions[0].outcome,
ExecutionOutcome::Skipped
));
assert!(matches!(
result.executions[1].outcome,
ExecutionOutcome::Skipped
));
assert_eq!(result.failed_count, 0);
}
#[test]
fn failed_actions_recorded_but_run_continues() {
let plan = ReconcilePlan {
actions: vec![
file_new("a"),
ReconcileAction::Close {
number: 1,
category: "b".into(),
comment: "c".into(),
},
file_new("c"),
],
};
let mut tracker = MockTracker::new();
tracker.fail_close = true;
let result = apply_plan(plan, &tracker).unwrap();
assert_eq!(result.executions.len(), 3);
assert_eq!(result.failed_count, 1);
assert!(matches!(
result.executions[0].outcome,
ExecutionOutcome::Filed { .. }
));
assert!(matches!(
result.executions[1].outcome,
ExecutionOutcome::Failed { .. }
));
assert!(matches!(
result.executions[2].outcome,
ExecutionOutcome::Filed { .. }
));
}
#[test]
fn update_closed_passes_no_title_to_tracker() {
let plan = ReconcilePlan {
actions: vec![ReconcileAction::UpdateClosed {
number: 50,
body: "fresh".into(),
category: "x".into(),
count: 99,
}],
};
let tracker = MockTracker::new();
apply_plan(plan, &tracker).unwrap();
assert_eq!(tracker.calls.borrow()[0], "update:#50:-");
}
}