tix/sync/import.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// `tix import`: pull open remote issues into local files.
5///
6/// Conflict resolution: a remote issue that already tracks a local one skips
7/// the local file when the local `updated` is later or equal; when the remote
8/// `updated_at` is newer the local file is overwritten (fields + body, keeping
9/// `created`), with the previous file first copied into the centralized backup
10/// directory (`.tix-backups/` beside the issues directory, #1581).
11///
12/// `policy` is the explicit triage-entry policy (WP07 T034). It decides **only
13/// where a newly created file lands and what the report says about it**: the
14/// conflict rules above, remote ids, and adapter behaviour are untouched, and
15/// the overwrite path never consults it (T036).
16///
17/// Returns the number of local files created or overwritten.
18pub fn import(
19 store: &Store,
20 tool: Tool,
21 config: &Config,
22 out: &mut dyn Write,
23 policy: crate::triage::import_policy::ImportPolicy,
24) -> Result<SyncReport> {
25 let mut adapter = adapter_for(tool, config)?;
26 import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
27}
28
29/// [`import`] with the adapter injected — the [`CommandRunner`] pattern, so the
30/// orchestration under test is the real one and the remote side is a fake that
31/// returns canned [`RemoteIssue`]s without spawning a process (WP07 T037).
32///
33/// `command`s like `tix import` still go through [`import`]; this entry point
34/// exists so a test can hold every input of the triage policy in one place —
35/// config, policy, and the remote payloads — instead of installing a fake
36/// binary on `PATH` and running the compiled CLI to reach it.
37#[allow(clippy::too_many_lines)]
38pub fn import_with_adapter(
39 store: &Store,
40 tool: Tool,
41 config: &Config,
42 out: &mut dyn Write,
43 policy: crate::triage::import_policy::ImportPolicy,
44 adapter: &mut dyn TrackerAdapter,
45) -> Result<SyncReport> {
46 // Resolved **before** the loop: a policy this project cannot represent
47 // refuses the whole import, not the third item of it.
48 let router = crate::triage::import_policy::resolve(config, policy)?;
49 let remote_issues = adapter.list_open()?;
50 let mut report = SyncReport::default().diagnostics_in_report();
51 let mut handled = 0;
52 // Remote ids imported this run, paired with the local number each one landed
53 // on. A parent is named by a **remote** id in the payload, so the reference can
54 // only be restored after the parent itself has been created — which is why
55 // this is a second pass rather than something set while building each issue.
56 let mut imported: Vec<(String, u32)> = Vec::new();
57 // `(child remote id, parent remote id)` for every payload that named one.
58 let mut claims: Vec<(String, String)> = Vec::new();
59 // `(remote issue id, the project references that payload reported)` — WP08.
60 // Collected for **every** payload, and applied after the issues exist, so a
61 // payload's order cannot decide whether an association lands (T037 step 4).
62 let mut project_payloads: Vec<(String, Vec<RemoteProject>)> = Vec::new();
63 // `(remote issue id, the epic references that payload reported)` — WP08. Same
64 // second-pass treatment as the project payloads, so the order a payload's issues
65 // arrive in cannot decide whether a reference lands.
66 let mut epic_payloads: Vec<(String, Vec<RemoteEpic>)> = Vec::new();
67 // `(remote issue id, the local number it landed on)` for every payload this run
68 // found a local counterpart for — created here or already tracked. Distinct from
69 // `imported`, which is create-only and is what the parent pass resolves
70 // references through.
71 let mut located: Vec<(String, u32)> = Vec::new();
72 for remote in remote_issues.clone() {
73 if let Some(parent) = remote.parent.clone() {
74 claims.push((remote.id.clone(), parent));
75 }
76 if !remote.projects.is_empty() {
77 project_payloads.push((remote.id.clone(), remote.projects.clone()));
78 }
79 if !remote.epics.is_empty() {
80 epic_payloads.push((remote.id.clone(), remote.epics.clone()));
81 }
82 let tracked = store
83 .list()?
84 .into_iter()
85 .find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
86 match tracked {
87 None => {
88 // WP07 T034/T035: the placement decision is made here, once, from
89 // the remote state and the configured entry column — before any
90 // byte of the file exists. `requested` comes from this module's
91 // own mapper rather than being re-derived by the policy, so the
92 // two answers can be compared in the report instead of guessed at.
93 let requested = remote_to_local_status(tool, &remote.state, config);
94 let mapping = router.place(&remote.state, &requested);
95 let mut issue = Issue {
96 projects: None,
97 id: None,
98 number: 0,
99 title: remote.title.clone(),
100 status: Status::new(mapping.landed.clone()),
101 labels: remote.labels.clone(),
102 priority: crate::model::Priority::Medium,
103 assignee: remote.assignee.clone(),
104 estimate: None,
105 commit: None,
106 gitlab_id: None,
107 linear_id: None,
108 mission: None,
109 task: None,
110 relationships: vec![],
111 related_issues: vec![],
112 files: vec![],
113 cycles: None,
114 milestones: None,
115 reviewer: None,
116 start_date: None,
117 due_date: None,
118 created: crate::model::today(),
119 type_: None,
120 updated: crate::model::today(),
121 filename: String::new(),
122 body: String::new(),
123 history: crate::model::History::new(),
124 has_retired_links_key: false,
125 time_entries: vec![],
126 parent: None,
127 series: None,
128 health: None,
129 epics: None,
130 extra_frontmatter: std::collections::BTreeMap::new(),
131 archived: false,
132 entered_at: None,
133 source: None,
134 };
135 // T035: entry metadata through the same seam every other entry
136 // uses. The queue's age is measured from `entered_at`, so an issue
137 // that entered the board through an import has to record that it
138 // did — and `import` is the origin data-model §2 documents for it.
139 //
140 // The **landing column** decides, not `mapping.routed`: `routed` is a
141 // policy notion, so under `--triage-policy remote` (or a default whose
142 // remote-mapped column happens to be the entry column) an imported
143 // issue landed in the inbox with no stamp and the queue reported
144 // `unknown` for an issue it was listing. On a project with no
145 // `[triage]` section the shared rule stamps nothing, which is the
146 // outcome the old guard was reaching for.
147 crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
148 match tool {
149 Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
150 Tool::Linear => issue.linear_id = Some(remote.id.clone()),
151 }
152 // WP07 T031: a **column check** on the incoming status, and only
153 // that. `remote_to_local_status` already lands on a column the board
154 // configures — the service's completion column, or a name match, or
155 // the first — so this is the belt to that braces, and it means an
156 // import can never create a file in a column the board lacks. The
157 // create path performs the same check (WP05), so this is belt *and*
158 // braces for the remote-sourced case.
159 //
160 // Not a transition evaluation: a new issue is not leaving anywhere, so
161 // the terminal lock has nothing to say. Importing an issue directly
162 // into a terminal column is legitimate.
163 let (_, resolution) = crate::workflow::active_resolution(config);
164 if resolution.resolve(issue.status.display()).is_none() {
165 // Reported, not written. A skipped item is visible and the rest of
166 // the batch still lands.
167 writeln!(
168 out,
169 "skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
170 remote.id, remote.title, issue.status, resolution.board
171 )?;
172 report.skipped += 1;
173 continue;
174 }
175 // One transaction per imported issue (#1580), never one over the
176 // whole import: each remote issue has its own outcome, and a
177 // failure on one must not roll back the others. `create` claims a
178 // number and writes the file, so the claim and the bytes are one
179 // unit.
180 let path =
181 store.transaction(|| store.create(&mut issue, &remote.description, None))?;
182 imported.push((remote.id.clone(), issue.number));
183 located.push((remote.id.clone(), issue.number));
184 let name = path
185 .file_name()
186 .map(|n| n.to_string_lossy().to_string())
187 .unwrap_or_default();
188 // T035: the mapping is named in the same line that names the file,
189 // and `report_clause` is empty exactly when the default policy
190 // decided nothing — so a legacy project's report is byte-identical
191 // to what it was, and a project that routed says where the remote
192 // state asked for and where the issue landed.
193 let clause = mapping.report_clause();
194 writeln!(
195 out,
196 "imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
197 remote.id, remote.title, issue.number, issue.status
198 )?;
199 handled += 1;
200 }
201 Some(local) => {
202 // The payload found its local issue. Recorded before the branches
203 // below, because the association pass needs to attach a remote
204 // payload's projects to it whether or not this run also overwrote
205 // its fields — "kept" is a statement about title and body, not about
206 // membership.
207 located.push((remote.id.clone(), local.number));
208 if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
209 let backup = crate::backup::save(&store.issues_dir, &local.filename);
210 // Captured before `local` is moved into `overwritten`: the
211 // skip path restores the local status, and reading it back off
212 // `overwritten` after the assignment would restore the status the
213 // *incoming* value was about to overwrite.
214 let local_status = local.status.clone();
215 let mut overwritten = local;
216 let keep_created = overwritten.created.clone();
217 overwritten.title = remote.title.clone();
218 overwritten.body = remote.description.clone();
219 overwritten.labels = remote.labels.clone();
220 let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
221 // WP07 T031: validate the incoming status as a **transition**,
222 // because this *is* one — an existing issue moving columns.
223 //
224 // **Skip-and-report**, which is the disposition the design settles
225 // (T031 step 3). The alternative, apply-and-report, would make a
226 // board's policy non-binding for the one path nobody watches, and
227 // a remote whose states the board forbids would quietly win.
228 // Skipping keeps the local file truthful and puts the conflict in
229 // the report where a human will see it. Recorded here rather than
230 // in `data-model.md` §8, which is a protected path for this lane.
231 let (_, resolution) = crate::workflow::active_resolution(config);
232 let transition = crate::workflow::validate_transition(
233 &overwritten,
234 incoming.display(),
235 &resolution,
236 config,
237 None,
238 );
239 if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
240 // The status is left alone; the other fields still import,
241 // because they are not what the policy governs. The file is
242 // rewritten, so this is a **partial** import — reported as such
243 // rather than as a plain overwrite.
244 overwritten.status = local_status.clone();
245 overwritten.title = remote.title.clone();
246 overwritten.body = remote.description.clone();
247 overwritten.labels = remote.labels.clone();
248 if let Some(a) = &remote.assignee {
249 overwritten.assignee = Some(a.clone());
250 }
251 overwritten.created = keep_created;
252 overwritten.bump_updated();
253 let filename = overwritten.filename.clone();
254 // One transaction for the item's corpus write (#1580): the
255 // `update` writes the file and may then reconcile its
256 // location, so those two mutations are one unit. The
257 // backup copy above is deliberately out-of-corpus and is
258 // not part of the journaled set.
259 store.transaction(|| store.update(&filename, &overwritten))?;
260 writeln!(
261 out,
262 "overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
263 (kept {})",
264 remote.id, overwritten.title, overwritten.status
265 )?;
266 report.skipped += 1;
267 handled += 1;
268 continue;
269 }
270 overwritten.status = incoming;
271 if let Some(a) = &remote.assignee {
272 overwritten.assignee = Some(a.clone());
273 }
274 overwritten.created = keep_created;
275 overwritten.bump_updated();
276 let filename = overwritten.filename.clone();
277
278 // An import that moves a locally-blocked issue into the
279 // board's completion column goes through the same blocked-move
280 // policy as every other surface -- WP04 T016 step 6 names
281 // "any bulk or import path" explicitly, and an import that
282 // can end an issue in the completion column with open
283 // blockers is the sharpest form of that.
284 //
285 // It **records** the acknowledgement rather than refusing, and
286 // the asymmetry is deliberate. There is no interactive
287 // confirmation available mid-import, and refusing would make
288 // `tix import` unusable whenever one issue in a batch carries
289 // a stale blocker -- turning a stale local edge into a
290 // permanently un-syncable board. The user ran the import;
291 // that is the decision, and the event exists to preserve it.
292 // Where a human is present to answer, refusing is right
293 // (`tix move`, `tix edit --status`, `move_issue`); here the
294 // honest record is that the import did it.
295 let guard = crate::commands::mv::blocked_move_guard(
296 config,
297 store,
298 &overwritten,
299 overwritten.status.display(),
300 )?;
301 if let Some(warning) = guard.warning() {
302 overwritten.history.append_now(
303 &crate::storage::actor_identity(),
304 crate::model::HistoryEventKind::BlockedMoveAcknowledged,
305 &format!("imported to {}: {warning}", overwritten.status.display()),
306 );
307 }
308 store.transaction(|| store.update(&filename, &overwritten))?;
309 let backup_label = backup
310 .as_ref()
311 .map(|p| p.display().to_string())
312 .unwrap_or_else(|| "none".to_string());
313 writeln!(
314 out,
315 "overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup_label})",
316 remote.id, overwritten.title
317 )?;
318 handled += 1;
319 } else {
320 writeln!(
321 out,
322 "kept {tool} issue {} \"{}\" (local updated later or equal)",
323 remote.id, remote.title
324 )?;
325 }
326 }
327 }
328 }
329 restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
330 apply_imported_projects(
331 store,
332 config,
333 tool,
334 &project_payloads,
335 &located,
336 &mut report,
337 out,
338 )?;
339 apply_imported_epics(
340 store,
341 config,
342 tool,
343 &epic_payloads,
344 &located,
345 &mut report,
346 out,
347 )?;
348 writeln!(out, "imported {handled} {tool} issue(s)")?;
349 report.handled = handled;
350 Ok(report)
351}
352
353/// Restore the parent references an import's payload reported.
354///
355/// A **second pass**, because a payload names a parent by *remote* id and the
356/// parent may well be imported after its child. Setting the field while building
357/// each issue would therefore succeed or fail depending on the order the remote
358/// happened to return, which is the order-dependence NFR-003 forbids.
359///
360/// Every write goes through [`crate::hierarchy::set_parent`] rather than a field
361/// assignment, so an imported reference obeys the same rules as a CLI write: it is
362/// validated before anything is written, and a refusal leaves the file
363/// byte-identical. T032's note is explicit — "an import that would create a cycle
364/// is a defect, not a special case" — and the service is what decides that, not
365/// this loop.
366///
367/// `claims` is `(child remote id, parent remote id)` and `imported` is
368/// `(remote id, local number)`. A pair where either side does not resolve becomes a
369/// diagnostic rather than a write: a reference to an issue this run did not import
370/// is exactly the unresolved-parent state WP05 added a finding for, and
371/// manufacturing it here would be this loop's own defect.
372pub(super) fn restore_imported_parents(
373 store: &Store,
374 tool: Tool,
375 claims: &[(String, String)],
376 imported: &[(String, u32)],
377 report: &mut SyncReport,
378 out: &mut dyn Write,
379) -> Result<()> {
380 for (child_remote, parent_remote) in claims {
381 let child = imported
382 .iter()
383 .find(|(id, _)| id == child_remote)
384 .map(|(_, number)| *number);
385 let parent = imported
386 .iter()
387 .find(|(id, _)| id == parent_remote)
388 .map(|(_, number)| *number);
389 let (Some(child), Some(parent)) = (child, parent) else {
390 report.note_parent(
391 MappedParent::Unsupported(RemoteMappingDiagnostic {
392 relation_id: format!("{child_remote}->{parent_remote}"),
393 field: parent_field(tool).to_string(),
394 tool,
395 message: format!(
396 "remote issue {child_remote} reports parent {parent_remote}, \
397 which this import did not create — it may be closed, \
398 filtered out, or in another project; no local reference is \
399 written, so the issue stays parentless rather than \
400 unresolved"
401 ),
402 }),
403 out,
404 )?;
405 continue;
406 };
407 // Through `Store::set_parent`, which resolves, validates with the
408 // hierarchy service, writes, and records the one History entry — the
409 // same path `tix edit --parent` takes, so an imported reference is
410 // indistinguishable from a typed one. A refusal is reported and nothing is
411 // written.
412 let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
413 match store.set_parent(&child_sel, &parent_sel) {
414 Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
415 Err(e) => {
416 writeln!(
417 out,
418 "could not restore the parent of {tool} issue #{child}: {e}; the \
419 reference was not written"
420 )?;
421 report.diagnostics.push(RemoteMappingDiagnostic {
422 relation_id: format!("{child}->{parent}"),
423 field: parent_field(tool).to_string(),
424 tool,
425 message: format!(
426 "the remote reports #{child} under #{parent}, but the hierarchy \
427 service refused it: {e}"
428 ),
429 });
430 }
431 }
432 }
433 Ok(())
434}
435
436/// Apply the project associations an import's payloads reported (WP08 T037).
437///
438/// A **second pass**, like [`restore_imported_parents`], because the association
439/// belongs to an issue that must exist locally first and because the order a
440/// remote happened to list its issues in must not decide the outcome (T037 step
441/// 4). Every payload that found a local issue is in `located`, so an issue created
442/// *after* its sibling still gets its memberships.
443///
444/// ## Every write goes through the project service
445///
446/// [`Store::associate`] is the same path `tix project add` will take, so
447/// uniqueness, reference validation and the one History entry per accepted change
448/// are the service's decisions rather than a second membership rule here. An
449/// already-present association is the service's idempotent no-op: it writes
450/// nothing, appends nothing, and leaves the canonical file byte-stable — which is
451/// what makes a repeated import a true no-op (T037 step 3).
452///
453/// ## Matching is by id, and only by id
454///
455/// `resolve_project_id` also accepts a project *name*, because a human selects
456/// projects by name. An import must not: a name is a display label (C-002), so a
457/// remote that renames a project — or a local registry whose name happens to
458/// equal the remote's id — would resolve to the wrong project, or to a second
459/// association for a membership that is already there. The registry is therefore
460/// consulted by **key** before the service is called, which both enforces
461/// id-only matching and makes the refusal diagnosable instead of an
462/// `unknown_project` error string.
463///
464/// ## An unknown id is diagnosed, never written
465///
466/// Writing the association would manufacture a dangling reference — one per
467/// issue, per run, for a project the registry has never heard of: the "missing
468/// reference storm" T037 step 4 names. The remote keeps the association, the
469/// local file is untouched, and the diagnostic names the field, the tool and the
470/// id (C-005). The registry itself is **never** written: the local
471/// `[projects.<id>]` section is this repository's configuration (C-001) and not
472/// the remote's to rewrite, which is also why there is no window in which a
473/// partially written registry could exist.
474///
475/// A payload the import skipped — an issue whose remote state is not a column on
476/// the board — was already reported on its own line and has no local issue to
477/// attach a membership to, so it is passed over here rather than diagnosed twice.
478pub(super) fn apply_imported_projects(
479 store: &Store,
480 config: &Config,
481 tool: Tool,
482 payloads: &[(String, Vec<RemoteProject>)],
483 located: &[(String, u32)],
484 report: &mut SyncReport,
485 out: &mut dyn Write,
486) -> Result<()> {
487 if payloads.is_empty() {
488 return Ok(());
489 }
490 // Resolved once, with a **no-op** warning sink: this is the same resolution the
491 // adapters use, and quoting the project name the remote reported is the one
492 // place remote output reaches a diagnostic, so it has to be scrubbed. The sink
493 // is silent because `token::resolve` already warned on the adapter's behalf for
494 // this run — warning twice per import would be noise, not extra safety.
495 let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
496 .secret()
497 .map(str::to_string);
498 for (remote_id, projects) in payloads {
499 let Some(local_number) = located
500 .iter()
501 .find(|(id, _)| id == remote_id)
502 .map(|(_, number)| *number)
503 else {
504 continue;
505 };
506 for project in projects {
507 let project_id = match map_project_from_remote(tool, project) {
508 MappedProject::Remote { value, .. } => value,
509 MappedProject::Unsupported(mut diagnostic) => {
510 scrub_diagnostic(&mut diagnostic, secret.as_deref());
511 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
512 continue;
513 }
514 };
515 if !config.projects.contains_key(project_id.as_str()) {
516 let mut diagnostic = RemoteMappingDiagnostic {
517 relation_id: format!("{local_number}->{project_id}"),
518 field: project_field(tool).to_string(),
519 tool,
520 message: format!(
521 "the remote reports #{local_number} in project `{project_id}`{}, \
522 which this repository's registry does not declare; nothing was \
523 written — the association stays on the remote and no missing \
524 reference was created locally",
525 project
526 .name
527 .as_deref()
528 .map(|name| format!(" (`{name}`)"))
529 .unwrap_or_default()
530 ),
531 };
532 scrub_diagnostic(&mut diagnostic, secret.as_deref());
533 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
534 continue;
535 }
536 match store.associate(&local_number.to_string(), &project_id) {
537 Ok(applied) if applied.changed => {
538 writeln!(
539 out,
540 "associated {tool} issue #{local_number} with {}",
541 crate::projects::association_detail(&project_id, true)
542 )?;
543 }
544 // Already associated: the service's no-op, so nothing was written and
545 // no History entry was appended. Silent on purpose — a report line per
546 // unchanged association would make every re-import look like work.
547 Ok(_) => {}
548 Err(error) => {
549 let mut diagnostic = RemoteMappingDiagnostic {
550 relation_id: format!("{local_number}->{project_id}"),
551 field: project_field(tool).to_string(),
552 tool,
553 message: format!(
554 "the remote reports #{local_number} in project `{project_id}`, \
555 but the project service refused the association: {error}; the \
556 local association list is unchanged"
557 ),
558 };
559 scrub_diagnostic(&mut diagnostic, secret.as_deref());
560 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
561 }
562 }
563 }
564 }
565 Ok(())
566}
567
568/// Apply the epic references an import's payloads reported (WP08, mission
569/// `epics-work-items-01M3BYW4`).
570///
571/// A **second pass**, like [`apply_imported_projects`], because the reference
572/// belongs to an issue that must exist locally first and because the order a remote
573/// happened to list its issues in must not decide the outcome. Every payload that
574/// found a local issue is in `located`, so an issue created *after* its sibling
575/// still gets its references.
576///
577/// ## Every write goes through the epic service
578///
579/// [`Store::reference_epic`] is the same path `tix edit --add-epic` takes, so
580/// uniqueness, reference validation and the one History entry per accepted change
581/// are the service's decisions rather than a second reference rule here. An
582/// already-present reference is the service's idempotent no-op: it writes nothing,
583/// appends nothing, and leaves the canonical file byte-stable — which is what makes
584/// a repeated import a true no-op (T039 step 3).
585///
586/// ## Matching is by id, and only by id
587///
588/// `resolve_epic_id` also accepts an epic *name*, because a human selects epics by
589/// name. An import must not: a name is a display label (C-003), so a remote that
590/// renames an epic — or a local registry whose name happens to equal the remote's id
591/// — would resolve to the wrong epic, or to a second reference for a reference that
592/// is already there. The registry is therefore consulted by **key** before the
593/// service is called, which both enforces id-only matching and makes the refusal
594/// diagnosable instead of an `unknown_epic` error string.
595///
596/// ## An unknown id is diagnosed, never written
597///
598/// Writing the reference would manufacture a dangling reference — one per issue, per
599/// run, for an epic the registry has never heard of: the "missing reference storm"
600/// T039 step 4 names. The remote keeps the reference, the local file is untouched,
601/// and the diagnostic names the field, the tool and the id (C-007). The registry
602/// itself is **never** written: the local `[epics.<id>]` section is this repository's
603/// configuration (C-004) and not the remote's to rewrite, which is also why there is
604/// no window in which a partially written registry could exist.
605///
606/// A payload the import skipped — an issue whose remote state is not a column on the
607/// board — was already reported on its own line and has no local issue to attach a
608/// reference to, so it is passed over here rather than diagnosed twice.
609pub(super) fn apply_imported_epics(
610 store: &Store,
611 config: &Config,
612 tool: Tool,
613 payloads: &[(String, Vec<RemoteEpic>)],
614 located: &[(String, u32)],
615 report: &mut SyncReport,
616 out: &mut dyn Write,
617) -> Result<()> {
618 if payloads.is_empty() {
619 return Ok(());
620 }
621 // Resolved once, with a **no-op** warning sink: the same resolution the adapters
622 // use, and quoting the epic name the remote reported is the one place remote
623 // output reaches a diagnostic, so it has to be scrubbed. The sink is silent
624 // because `token::resolve` already warned on the adapter's behalf for this run.
625 let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
626 .secret()
627 .map(str::to_string);
628 for (remote_id, epics) in payloads {
629 let Some(local_number) = located
630 .iter()
631 .find(|(id, _)| id == remote_id)
632 .map(|(_, number)| *number)
633 else {
634 continue;
635 };
636 for epic in epics {
637 let epic_id = match map_epic_from_remote(tool, epic) {
638 MappedEpic::Remote { value, .. } => value,
639 MappedEpic::Unsupported(mut diagnostic) => {
640 scrub_diagnostic(&mut diagnostic, secret.as_deref());
641 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
642 continue;
643 }
644 };
645 if !config.epics.contains_key(epic_id.as_str()) {
646 let mut diagnostic = RemoteMappingDiagnostic {
647 relation_id: format!("{local_number}->{epic_id}"),
648 field: EPIC_FIELD.to_string(),
649 tool,
650 message: format!(
651 "the remote reports #{local_number} in epic `{epic_id}`{}, which \
652 this repository's registry does not declare; nothing was written \
653 — the reference stays on the remote and no missing reference \
654 was created locally",
655 epic.name
656 .as_deref()
657 .map(|name| format!(" (`{name}`)"))
658 .unwrap_or_default()
659 ),
660 };
661 scrub_diagnostic(&mut diagnostic, secret.as_deref());
662 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
663 continue;
664 }
665 match store.reference_epic(&local_number.to_string(), &epic_id) {
666 Ok(applied) if applied.changed => {
667 writeln!(
668 out,
669 "referenced {tool} issue #{local_number} with {}",
670 crate::epics::reference_detail(&epic_id, true)
671 )?;
672 }
673 // Already referenced: the service's no-op, so nothing was written and
674 // no History entry was appended. Silent on purpose — a report line per
675 // unchanged reference would make every re-import look like work.
676 Ok(_) => {}
677 Err(error) => {
678 let mut diagnostic = RemoteMappingDiagnostic {
679 relation_id: format!("{local_number}->{epic_id}"),
680 field: EPIC_FIELD.to_string(),
681 tool,
682 message: format!(
683 "the remote reports #{local_number} in epic `{epic_id}`, but \
684 the epic service refused the reference: {error}; the local \
685 reference list is unchanged"
686 ),
687 };
688 scrub_diagnostic(&mut diagnostic, secret.as_deref());
689 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
690 }
691 }
692 }
693 }
694 Ok(())
695}
696
697/// Whether a computed **remote** status conveys "done" in that tracker's own
698/// vocabulary.
699///
700/// Deliberately **not** the completion decision. `closed` is GitLab's word and
701/// `Done` is Linear's; neither is a local column name, so this cannot be a
702/// category question, and routing it through the service would mean asking the
703/// service about a string from a different vocabulary.
704///
705/// It used to decide *local* completion at the export call sites, by mapping the
706/// local status out to a remote string and testing that — a round trip that can
707/// disagree with the category rule on any board with a category_map. Those call
708/// sites now ask \`workflow::is_completed\` about the local issue (C-004), and this
709/// function keeps only the narrower job it can actually do: telling an adapter
710/// whether its own status update already closed the remote issue.
711pub(super) fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
712 match tool {
713 Tool::GitLab => issue.gitlab_id.as_deref(),
714 Tool::Linear => issue.linear_id.as_deref(),
715 }
716}