#[allow(clippy::wildcard_imports)]
use super::*;
pub fn import(
store: &Store,
tool: Tool,
config: &Config,
out: &mut dyn Write,
policy: crate::triage::import_policy::ImportPolicy,
) -> Result<SyncReport> {
let mut adapter = adapter_for(tool, config)?;
import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
}
#[allow(clippy::too_many_lines)]
pub fn import_with_adapter(
store: &Store,
tool: Tool,
config: &Config,
out: &mut dyn Write,
policy: crate::triage::import_policy::ImportPolicy,
adapter: &mut dyn TrackerAdapter,
) -> Result<SyncReport> {
let router = crate::triage::import_policy::resolve(config, policy)?;
let remote_issues = adapter.list_open()?;
let mut report = SyncReport::default().diagnostics_in_report();
let mut handled = 0;
let mut imported: Vec<(String, u32)> = Vec::new();
let mut claims: Vec<(String, String)> = Vec::new();
let mut project_payloads: Vec<(String, Vec<RemoteProject>)> = Vec::new();
let mut epic_payloads: Vec<(String, Vec<RemoteEpic>)> = Vec::new();
let mut located: Vec<(String, u32)> = Vec::new();
for remote in remote_issues.clone() {
if let Some(parent) = remote.parent.clone() {
claims.push((remote.id.clone(), parent));
}
if !remote.projects.is_empty() {
project_payloads.push((remote.id.clone(), remote.projects.clone()));
}
if !remote.epics.is_empty() {
epic_payloads.push((remote.id.clone(), remote.epics.clone()));
}
let tracked = store
.list()?
.into_iter()
.find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
match tracked {
None => {
let requested = remote_to_local_status(tool, &remote.state, config);
let mapping = router.place(&remote.state, &requested);
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: remote.title.clone(),
status: Status::new(mapping.landed.clone()),
labels: remote.labels.clone(),
priority: crate::model::Priority::Medium,
assignee: remote.assignee.clone(),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: crate::model::today(),
type_: None,
updated: crate::model::today(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
match tool {
Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
Tool::Linear => issue.linear_id = Some(remote.id.clone()),
}
let (_, resolution) = crate::workflow::active_resolution(config);
if resolution.resolve(issue.status.display()).is_none() {
writeln!(
out,
"skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
remote.id, remote.title, issue.status, resolution.board
)?;
report.skipped += 1;
continue;
}
let path =
store.transaction(|| store.create(&mut issue, &remote.description, None))?;
imported.push((remote.id.clone(), issue.number));
located.push((remote.id.clone(), issue.number));
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let clause = mapping.report_clause();
writeln!(
out,
"imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
remote.id, remote.title, issue.number, issue.status
)?;
handled += 1;
}
Some(local) => {
located.push((remote.id.clone(), local.number));
if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
let backup = crate::backup::save(&store.issues_dir, &local.filename);
let local_status = local.status.clone();
let mut overwritten = local;
let keep_created = overwritten.created.clone();
overwritten.title = remote.title.clone();
overwritten.body = remote.description.clone();
overwritten.labels = remote.labels.clone();
let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
let (_, resolution) = crate::workflow::active_resolution(config);
let transition = crate::workflow::validate_transition(
&overwritten,
incoming.display(),
&resolution,
config,
None,
);
if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
overwritten.status = local_status.clone();
overwritten.title = remote.title.clone();
overwritten.body = remote.description.clone();
overwritten.labels = remote.labels.clone();
if let Some(a) = &remote.assignee {
overwritten.assignee = Some(a.clone());
}
overwritten.created = keep_created;
overwritten.bump_updated();
let filename = overwritten.filename.clone();
store.transaction(|| store.update(&filename, &overwritten))?;
writeln!(
out,
"overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
(kept {})",
remote.id, overwritten.title, overwritten.status
)?;
report.skipped += 1;
handled += 1;
continue;
}
overwritten.status = incoming;
if let Some(a) = &remote.assignee {
overwritten.assignee = Some(a.clone());
}
overwritten.created = keep_created;
overwritten.bump_updated();
let filename = overwritten.filename.clone();
let guard = crate::commands::mv::blocked_move_guard(
config,
store,
&overwritten,
overwritten.status.display(),
)?;
if let Some(warning) = guard.warning() {
overwritten.history.append_now(
&crate::storage::actor_identity(),
crate::model::HistoryEventKind::BlockedMoveAcknowledged,
&format!("imported to {}: {warning}", overwritten.status.display()),
);
}
store.transaction(|| store.update(&filename, &overwritten))?;
let backup_label = backup
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "none".to_string());
writeln!(
out,
"overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup_label})",
remote.id, overwritten.title
)?;
handled += 1;
} else {
writeln!(
out,
"kept {tool} issue {} \"{}\" (local updated later or equal)",
remote.id, remote.title
)?;
}
}
}
}
restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
apply_imported_projects(
store,
config,
tool,
&project_payloads,
&located,
&mut report,
out,
)?;
apply_imported_epics(
store,
config,
tool,
&epic_payloads,
&located,
&mut report,
out,
)?;
writeln!(out, "imported {handled} {tool} issue(s)")?;
report.handled = handled;
Ok(report)
}
pub(super) fn restore_imported_parents(
store: &Store,
tool: Tool,
claims: &[(String, String)],
imported: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
for (child_remote, parent_remote) in claims {
let child = imported
.iter()
.find(|(id, _)| id == child_remote)
.map(|(_, number)| *number);
let parent = imported
.iter()
.find(|(id, _)| id == parent_remote)
.map(|(_, number)| *number);
let (Some(child), Some(parent)) = (child, parent) else {
report.note_parent(
MappedParent::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{child_remote}->{parent_remote}"),
field: parent_field(tool).to_string(),
tool,
message: format!(
"remote issue {child_remote} reports parent {parent_remote}, \
which this import did not create — it may be closed, \
filtered out, or in another project; no local reference is \
written, so the issue stays parentless rather than \
unresolved"
),
}),
out,
)?;
continue;
};
let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
match store.set_parent(&child_sel, &parent_sel) {
Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
Err(e) => {
writeln!(
out,
"could not restore the parent of {tool} issue #{child}: {e}; the \
reference was not written"
)?;
report.diagnostics.push(RemoteMappingDiagnostic {
relation_id: format!("{child}->{parent}"),
field: parent_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{child} under #{parent}, but the hierarchy \
service refused it: {e}"
),
});
}
}
}
Ok(())
}
pub(super) fn apply_imported_projects(
store: &Store,
config: &Config,
tool: Tool,
payloads: &[(String, Vec<RemoteProject>)],
located: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
if payloads.is_empty() {
return Ok(());
}
let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
.secret()
.map(str::to_string);
for (remote_id, projects) in payloads {
let Some(local_number) = located
.iter()
.find(|(id, _)| id == remote_id)
.map(|(_, number)| *number)
else {
continue;
};
for project in projects {
let project_id = match map_project_from_remote(tool, project) {
MappedProject::Remote { value, .. } => value,
MappedProject::Unsupported(mut diagnostic) => {
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
continue;
}
};
if !config.projects.contains_key(project_id.as_str()) {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{project_id}"),
field: project_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{local_number} in project `{project_id}`{}, \
which this repository's registry does not declare; nothing was \
written — the association stays on the remote and no missing \
reference was created locally",
project
.name
.as_deref()
.map(|name| format!(" (`{name}`)"))
.unwrap_or_default()
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
continue;
}
match store.associate(&local_number.to_string(), &project_id) {
Ok(applied) if applied.changed => {
writeln!(
out,
"associated {tool} issue #{local_number} with {}",
crate::projects::association_detail(&project_id, true)
)?;
}
Ok(_) => {}
Err(error) => {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{project_id}"),
field: project_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{local_number} in project `{project_id}`, \
but the project service refused the association: {error}; the \
local association list is unchanged"
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
}
}
}
}
Ok(())
}
pub(super) fn apply_imported_epics(
store: &Store,
config: &Config,
tool: Tool,
payloads: &[(String, Vec<RemoteEpic>)],
located: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
if payloads.is_empty() {
return Ok(());
}
let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
.secret()
.map(str::to_string);
for (remote_id, epics) in payloads {
let Some(local_number) = located
.iter()
.find(|(id, _)| id == remote_id)
.map(|(_, number)| *number)
else {
continue;
};
for epic in epics {
let epic_id = match map_epic_from_remote(tool, epic) {
MappedEpic::Remote { value, .. } => value,
MappedEpic::Unsupported(mut diagnostic) => {
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
continue;
}
};
if !config.epics.contains_key(epic_id.as_str()) {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{epic_id}"),
field: EPIC_FIELD.to_string(),
tool,
message: format!(
"the remote reports #{local_number} in epic `{epic_id}`{}, which \
this repository's registry does not declare; nothing was written \
— the reference stays on the remote and no missing reference \
was created locally",
epic.name
.as_deref()
.map(|name| format!(" (`{name}`)"))
.unwrap_or_default()
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
continue;
}
match store.reference_epic(&local_number.to_string(), &epic_id) {
Ok(applied) if applied.changed => {
writeln!(
out,
"referenced {tool} issue #{local_number} with {}",
crate::epics::reference_detail(&epic_id, true)
)?;
}
Ok(_) => {}
Err(error) => {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{epic_id}"),
field: EPIC_FIELD.to_string(),
tool,
message: format!(
"the remote reports #{local_number} in epic `{epic_id}`, but \
the epic service refused the reference: {error}; the local \
reference list is unchanged"
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
}
}
}
}
Ok(())
}
pub(super) fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
match tool {
Tool::GitLab => issue.gitlab_id.as_deref(),
Tool::Linear => issue.linear_id.as_deref(),
}
}