Skip to main content

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}