use std::collections::BTreeMap;
use crate::bulk::apply::{ApplyReport, ItemOutcome};
use crate::bulk::plan::BatchPlan;
use crate::bulk::{ArchiveRequest, IssuePatch, Selection};
use crate::error::{Result, TixError};
use crate::storage::Store;
pub const ARCHIVE_OWNER: &str = "issue-archive-restore-01M3C4JF";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveItemRequest {
pub number: u32,
pub selector: String,
pub operation: ArchiveOperation,
pub target_status: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveOperation {
Archive,
Restore,
}
impl ArchiveOperation {
pub fn service_fn(self) -> &'static str {
match self {
Self::Archive => "bulk_archive",
Self::Restore => "bulk_restore",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Archive => "archive",
Self::Restore => "restore",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveBatchRequest {
pub operation: ArchiveOperation,
pub items: Vec<ArchiveItemRequest>,
}
impl ArchiveBatchRequest {
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn summary(&self) -> String {
format!(
"{} {} request for {} issue{}",
self.operation.service_fn(),
self.operation.as_str(),
self.items.len(),
if self.items.len() == 1 { "" } else { "s" }
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveProblem {
MissingTargetStatus,
UnknownIssue,
}
impl ArchiveProblem {
pub fn as_str(self) -> &'static str {
match self {
Self::MissingTargetStatus => "missing_target_status",
Self::UnknownIssue => "unknown_issue",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveDiagnostic {
pub problem: ArchiveProblem,
pub issue: Option<u32>,
pub message: String,
}
impl std::fmt::Display for ArchiveDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.issue {
Some(issue) => write!(f, "#{}: {}: {}", issue, self.problem.as_str(), self.message),
None => write!(f, "{}: {}", self.problem.as_str(), self.message),
}
}
}
pub fn adapt(
patch: &IssuePatch,
numbers: &[u32],
selectors: &BTreeMap<u32, String>,
) -> std::result::Result<ArchiveBatchRequest, ArchiveDiagnostic> {
let request = patch.archive.clone().ok_or_else(|| ArchiveDiagnostic {
problem: ArchiveProblem::UnknownIssue,
issue: None,
message: "this patch carries no archive request".to_string(),
})?;
Ok(build(request, numbers, selectors))
}
fn build(
request: ArchiveRequest,
numbers: &[u32],
selectors: &BTreeMap<u32, String>,
) -> ArchiveBatchRequest {
let operation = if request.archive {
ArchiveOperation::Archive
} else {
ArchiveOperation::Restore
};
let target_status = if request.archive {
None
} else {
request.target_status.clone()
};
let items = numbers
.iter()
.map(|number| ArchiveItemRequest {
number: *number,
selector: selectors
.get(number)
.cloned()
.unwrap_or_else(|| number.to_string()),
operation,
target_status: target_status.clone(),
reason: request.reason.clone(),
})
.collect();
ArchiveBatchRequest { operation, items }
}
pub fn refusal(request: &ArchiveBatchRequest) -> Option<ArchiveDiagnostic> {
if request.operation != ArchiveOperation::Restore {
return None;
}
if request
.items
.iter()
.all(|item| item.target_status.is_some())
{
return None;
}
Some(ArchiveDiagnostic {
problem: ArchiveProblem::MissingTargetStatus,
issue: None,
message: "restoring needs an explicit target status — there is no \\
\"restore to the previous status\", so name the column to restore \\
into"
.to_string(),
})
}
pub fn predicted_history_events(patch: &IssuePatch) -> usize {
usize::from(patch.archive.is_some())
}
pub fn history_kind_for(patch: &IssuePatch) -> Option<&'static str> {
patch.archive.as_ref().map(|request| {
if request.archive {
"archived"
} else {
"restored"
}
})
}
pub fn apply_archive(store: &Store, plan: &BatchPlan) -> Result<ApplyReport> {
if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
return Err(TixError::InvalidInput(
"a bulk apply needs a confirmation for exactly this plan — preview it \
first, and preview again if the selection changed"
.to_string(),
));
}
let request = plan
.patch
.archive
.clone()
.ok_or_else(|| TixError::InvalidInput("this plan carries no archive request".into()))?;
if !request.archive {
if request.target_status.is_none() {
return Err(TixError::InvalidInput(format!(
"{}: restoring needs an explicit target status — there is no \\
\"restore to the previous status\"",
ArchiveProblem::MissingTargetStatus.as_str()
)));
}
return Err(TixError::InvalidInput(format!(
"archive delegation is not available for a restore: this build wires \
`bulk archive` to the store's archive service, but the bulk restore \
direction is not wired — mission {ARCHIVE_OWNER} owns the target-status \
contract, so this will delegate to its `bulk_restore` once that lands"
)));
}
let batch = adapt(
&plan.patch,
&plan.selection.numbers(),
&selector_map(&plan.selection),
)
.map_err(|diagnostic| TixError::InvalidInput(diagnostic.to_string()))?;
let mut outcomes = Vec::with_capacity(batch.len());
for item in &batch.items {
let result = store.transaction(|| store.archive(&item.selector));
match result {
Ok(issue) => outcomes.push((
item.number,
ItemOutcome::Applied {
filename: issue.filename.clone(),
},
)),
Err(error) if already_archived(&error) => {
outcomes.push((item.number, ItemOutcome::NoOp))
}
Err(error) => outcomes.push((item.number, ItemOutcome::Rejected(error.to_string()))),
}
}
Ok(ApplyReport {
outcomes,
write_failures: Vec::new(),
})
}
fn selector_map(selection: &Selection) -> BTreeMap<u32, String> {
selection
.selectors()
.iter()
.zip(selection.issues())
.map(|(selector, issue)| (issue.number, selector.clone()))
.collect()
}
fn already_archived(error: &TixError) -> bool {
error.to_string().contains("already_archived")
}
pub fn can_apply(patch: &IssuePatch) -> bool {
patch
.archive
.as_ref()
.is_some_and(|request| request.archive)
}