tix/sync/mod.rs
1//! External tracker sync: orchestration for `tix export` / `tix import`,
2//! the [`Tool`] selector, the [`CommandRunner`] spawn seam, and the pure
3//! mapping / conflict-resolution rules.
4//!
5//! WP05 wires the CLI to `glab` (GitLab) and `linear-cli` (Linear) under
6//! `contracts/external-tools.md`. Tokens are resolved through
7//! [`token::resolve`] with env precedence, injected only as an env pair (never
8//! argv), and every error/report surface scrubs the secret as `<token>`.
9//!
10//! The orchestration lives here so it is testable at module granularity by
11//! pointing PATH at fake tool scripts; [`export`] / [`import`] are pure in the
12//! sense that they take the store, tool, config and an output sink, and the
13//! only externally visible side effects are local issue files, `tix.toml`,
14//! the tool subprocesses, and what is written to `out`.
15//!
16//! ## One definition of "complete", and what this module's job actually is
17//!
18//! **A second local completion definition in this module is a C-004 defect.** There
19//! used to be one: `remote_status_is_done(status) == (status == "closed" ||
20//! status == "Done")`, a helper that looked like it decided whether an issue was
21//! complete and in fact decided whether a *remote* state word was one of the two
22//! this module happens to send. It had **no production callers** — WP02 removed the
23//! last one — and its only remaining referrer was its own unit test, which is the
24//! shape dead code takes when a test keeps it alive. Removed in WP07.
25//!
26//! What this module legitimately owns is the **translation**: the remote trackers
27//! have a closed set of states (`opened`/`closed` for GitLab, a workflow-state label
28//! for Linear), so something here has to say which remote word means closed and which
29//! local column to send it as. That is a protocol concern and it is this module's
30//! job.
31//!
32//! What it does **not** own is the meaning of *complete*. That is
33//! [`crate::workflow::is_completed`] and nothing else — so [`local_to_remote_status`]
34//! asks the service whether a local column is complete, and
35//! [`remote_to_local_status`] picks the column the service calls complete via
36//! [`completion_column`].
37//!
38//! The distinction matters because the two can disagree. A board whose `category_map`
39//! puts `completed` on `shipped` while `done_column` is `done` has a completion
40//! column that is *not* the one `done_column` names: comparing names exports
41//! `shipped` as open and closes `done` instead, so the tracker and the board
42//! disagree about the same issue in both directions.
43
44use std::collections::HashMap;
45use std::collections::HashSet;
46use std::io::Write;
47
48use crate::config::{BoardConfiguration, Config};
49use crate::error::{Result, TixError};
50use crate::model::{Issue, Status};
51use crate::storage::Store;
52
53pub mod beads;
54pub mod glab;
55pub mod linear;
56pub mod token;
57
58/// The supported external tracker, selected via `--target gitlab|linear`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Tool {
61 GitLab,
62 Linear,
63}
64
65impl Tool {
66 #[must_use]
67 pub fn from_target(target: &str) -> Result<Tool> {
68 match target {
69 "gitlab" => Ok(Tool::GitLab),
70 "linear" => Ok(Tool::Linear),
71 other => Err(TixError::InvalidInput(format!(
72 "unknown --target '{other}' — use gitlab or linear"
73 ))),
74 }
75 }
76
77 /// The token env var that wins over any `tix.toml` token — the tool's
78 /// **primary** name, which is what the `tix.toml` warning and the injection
79 /// tests name.
80 #[must_use]
81 pub fn token_var(self) -> &'static str {
82 match self {
83 Tool::GitLab => "GITLAB_TOKEN",
84 Tool::Linear => "LINEAR_TOKEN",
85 }
86 }
87
88 /// Every environment variable that can carry this tool's token, in
89 /// precedence order.
90 ///
91 /// `token_var` is the primary name tix documents; the extras are the names
92 /// the **tool itself** already reads, so a user who has the tracker
93 /// configured does not have to export a second, tix-specific variable. The
94 /// canonical `linear-cli` reads `LINEAR_API_KEY` (its help shows
95 /// `printf '%s\n' "$LINEAR_API_KEY" | linear config set-key`), so that is
96 /// accepted after tix's own `LINEAR_TOKEN`.
97 #[must_use]
98 pub fn token_vars(self) -> &'static [&'static str] {
99 match self {
100 Tool::GitLab => &["GITLAB_TOKEN"],
101 Tool::Linear => &["LINEAR_TOKEN", "LINEAR_API_KEY"],
102 }
103 }
104
105 /// The binary shelled out to.
106 #[must_use]
107 pub fn binary(self) -> &'static str {
108 match self {
109 Tool::GitLab => "glab",
110 Tool::Linear => "linear-cli",
111 }
112 }
113
114 #[must_use]
115 pub fn as_str(self) -> &'static str {
116 match self {
117 Tool::GitLab => "gitlab",
118 Tool::Linear => "linear",
119 }
120 }
121}
122
123impl std::fmt::Display for Tool {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.write_str(self.as_str())
126 }
127}
128
129/// The captured result of a spawned external tool.
130///
131/// `ProcessRunner` waits synchronously on the child (never kills it), so a
132/// blocking tool blocks the sync — the contract's supported "wait" model for
133/// `glab`/`linear-cli`.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct CommandOutcome {
136 pub success: bool,
137 pub status: Option<i32>,
138 pub stdout: String,
139 pub stderr: String,
140}
141
142impl CommandOutcome {
143 /// The human-readable output chosen for error reporting: stderr when the
144 /// tool failed and produced any, otherwise stdout.
145 #[must_use]
146 pub fn text(&self) -> &str {
147 if self.success {
148 &self.stdout
149 } else if !self.stderr.trim().is_empty() {
150 &self.stderr
151 } else {
152 &self.stdout
153 }
154 }
155}
156
157/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
158/// `FakeRunner` that records argv/env without spawning anything.
159pub trait CommandRunner: Send {
160 fn run(
161 &self,
162 program: &str,
163 args: &[String],
164 env: &[(String, String)],
165 ) -> Result<CommandOutcome>;
166
167 /// Downcasting hook so tests can inspect recorded calls on a fake.
168 fn as_any(&self) -> &dyn std::any::Any;
169}
170
171/// Production runner: `std::process::Command` with a synchronous output wait.
172///
173/// A missing binary surfaces as [`TixError::External`] with the contract's
174/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
175pub struct ProcessRunner;
176
177impl CommandRunner for ProcessRunner {
178 fn run(
179 &self,
180 program: &str,
181 args: &[String],
182 env: &[(String, String)],
183 ) -> Result<CommandOutcome> {
184 use std::process::Command;
185 let mut cmd = Command::new(program);
186 cmd.args(args);
187 for (key, value) in env {
188 cmd.env(key, value);
189 }
190 let output = cmd.output().map_err(|e| {
191 if e.kind() == std::io::ErrorKind::NotFound {
192 TixError::External(not_found_message(program))
193 } else {
194 TixError::External(format!("`{program}` failed to start: {e}"))
195 }
196 })?;
197 Ok(CommandOutcome {
198 success: output.status.success(),
199 status: output.status.code(),
200 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
201 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
202 })
203 }
204
205 fn as_any(&self) -> &dyn std::any::Any {
206 self
207 }
208}
209
210/// Common surface both tracker adapters implement so the orchestration is
211/// tool-agnostic.
212///
213/// `status` is the *remote* state/label already computed by the pure mapping
214/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
215/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
216/// conveys it with `-s Done` ([`TrackerAdapter::done_via_status`]).
217pub trait TrackerAdapter {
218 fn tool(&self) -> Tool;
219
220 /// Create a remote issue and return its remote id (GitLab `iid`, Linear
221 /// `identifier`).
222 fn create_issue(
223 &mut self,
224 title: &str,
225 body: &str,
226 labels: &[String],
227 assignee: Option<&str>,
228 priority: Option<&str>,
229 status: &str,
230 ) -> Result<String>;
231
232 /// Update an existing remote issue in place.
233 #[allow(clippy::too_many_arguments)]
234 fn update_issue(
235 &mut self,
236 remote_ref: &str,
237 title: &str,
238 body: &str,
239 labels: &[String],
240 assignee: Option<&str>,
241 priority: Option<&str>,
242 status: &str,
243 ) -> Result<()>;
244
245 /// Convey "done" (GitLab: `issue close <id>`). Only invoked when
246 /// `done_via_status` is false.
247 fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
248
249 /// List open remote issues (the import source). Closed/done issues are
250 /// excluded.
251 fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
252
253 /// Linear sets done through the status flag; GitLab needs a separate
254 /// close call.
255 fn done_via_status(&self) -> bool;
256
257 /// Send the relationships this tracker can express, after the issue itself
258 /// exists (WP07 T030/T031).
259 ///
260 /// **Required, not defaulted.** A default no-op would let a tracker be added
261 /// that silently never syncs relationships — the failure mode is a local
262 /// relation that appears to sync and does not, which is the same silent-loss
263 /// shape as a dropped edge. Two implementors is a small price for making the
264 /// omission a compile error.
265 ///
266 /// The relationships arrive already filtered by
267 /// [`map_relation_to_remote`], so an adapter never decides representability:
268 /// it sends what the shared seam says it can send, and reports the rest. A
269 /// `blocks` edge reaches here; a `related` edge never does.
270 fn set_relations(&mut self, remote_ref: &str, relations: &[MappedRelation]) -> Result<()>;
271
272 /// Send the project associations this tracker can express, after the issue
273 /// itself exists (WP08 T036).
274 ///
275 /// **Required, not defaulted**, for the same reason [`Self::set_relations`] is:
276 /// a default no-op would let a tracker be added that silently never syncs
277 /// project membership, which is the silent-loss shape C-005 forbids. Both
278 /// adapters implement `PROJECT_WIRE_VERIFIED`'s answer, which is "nothing", and
279 /// the signature is why a future tracker cannot answer with a different shape.
280 ///
281 /// The associations arrive already filtered by [`map_project_to_remote`], so an
282 /// adapter never decides representability — and while `PROJECT_WIRE_VERIFIED` is
283 /// empty it never receives a sendable value either, so both implementations
284 /// return without spawning anything. A run that cannot express membership must
285 /// send **nothing**: an empty membership flag that the tool interpreted as
286 /// "clear it" would delete remote state on a run that had nothing to say, which
287 /// is the same trap `set_relations` avoids by returning early on an empty set.
288 fn set_projects(&mut self, remote_ref: &str, projects: &[MappedProject]) -> Result<()>;
289
290 /// Send the epic references this tracker can express, after the issue itself
291 /// exists (WP08, mission `epics-work-items-01M3BYW4`).
292 ///
293 /// **Required, not defaulted**, for the reason [`Self::set_relations`] and
294 /// [`Self::set_projects`] give: a default no-op would let a tracker be added
295 /// that silently never syncs an epic reference, which is the silent-loss shape
296 /// C-007 forbids. The signature is why a future tracker cannot answer with a
297 /// different shape.
298 ///
299 /// The references arrive already filtered by [`map_epic_to_remote`], so an
300 /// adapter never decides representability. Both adapters implement
301 /// `EPIC_WIRE_VERIFIED`'s answer, which is "nothing": neither `glab` nor
302 /// `linear-cli` carries an epic membership list, so both return without spawning
303 /// anything, and an empty set must spawn nothing (an empty flag the tool read as
304 /// "clear it" would delete remote state on a run with nothing to say — the same
305 /// trap `set_relations` avoids by returning early).
306 fn set_epics(&mut self, remote_ref: &str, epics: &[MappedEpic]) -> Result<()>;
307}
308
309/// A remote issue parsed from the tracker's JSON list output.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct RemoteIssue {
312 /// Remote id (GitLab `iid`, Linear `identifier`).
313 pub id: String,
314 pub title: String,
315 pub description: String,
316 pub labels: Vec<String>,
317 /// Remote state (`closed` / `Done` / a column label, ...).
318 pub state: String,
319 /// ISO-8601 `updated_at`; `None` when the tool omitted it.
320 pub updated_at: Option<String>,
321 /// Best-effort single assignee (bare name, no `@`).
322 pub assignee: Option<String>,
323 /// The remote id of this issue's parent, when the remote reported one.
324 ///
325 /// A **remote** id, not a local number, because it is read from a remote
326 /// payload and the two id spaces are not interchangeable. It is resolved to a
327 /// local number by `local_parent_number` before anything is written, and
328 /// `None` — rather than a guess — when it cannot be resolved.
329 pub parent: Option<String>,
330 /// The project references the remote reported for this issue (WP08).
331 ///
332 /// Read from the payload, in the order the payload listed them, and matched
333 /// against the local registry by **id** before anything is written. An empty
334 /// vector is the answer for a payload that reported none, which is every
335 /// GitLab payload today: nothing in this build ever *sends* a membership, so
336 /// this is only ever what the remote side already said.
337 pub projects: Vec<RemoteProject>,
338 /// The epic references the remote reported for this issue (WP08, mission
339 /// `epics-work-items-01M3BYW4`).
340 ///
341 /// Read from the payload, in the order the payload listed them, and matched
342 /// against the local registry by **id** before anything is written. An empty
343 /// vector is the answer for a payload that reported none — and for both trackers
344 /// today nothing in this build ever *sends* one (`EPIC_WIRE_VERIFIED` is empty),
345 /// so this is only ever what the remote already said.
346 pub epics: Vec<RemoteEpic>,
347}
348
349/// What one export or import run did, and what it could not do.
350///
351/// Returned rather than only written to the caller's `Write`, so a caller can
352/// surface the diagnostics without scraping its own output — and so the count and
353/// the diagnostics cannot disagree. Both entry points had `Result<usize>`; a count
354/// alone cannot express "three issues synced, one relationship could not be".
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct SyncReport {
357 /// Issues created, updated, or overwritten.
358 pub handled: usize,
359 /// Local data the remote could not represent — relationships, parent
360 /// references, health values, project associations, and epic references — in
361 /// encounter order.
362 pub diagnostics: Vec<RemoteMappingDiagnostic>,
363 /// Items whose **status** the transition policy refused (WP07 T031).
364 ///
365 /// Counted separately from `handled` because an import can do both: an issue
366 /// whose title and body imported fine while its status was refused still counts
367 /// as handled — the file was written — and also as skipped, because a column did
368 /// not move. A single number cannot express that, and the report line it backs
369 /// (`overwrote … without its status`) is the one a user has to be able to find.
370 pub skipped: usize,
371}
372
373impl SyncReport {
374 /// Record one unrepresentable relationship: keep the diagnostic for the
375 /// caller, and say so on the human-facing stream too.
376 fn note(&mut self, mapped: MappedRelation, out: &mut dyn Write) -> std::io::Result<()> {
377 if let MappedRelation::Unsupported(diagnostic) = mapped {
378 writeln!(out, "{diagnostic}")?;
379 self.diagnostics.push(diagnostic);
380 }
381 Ok(())
382 }
383
384 /// The same, for a **parent** reference.
385 ///
386 /// Its own method rather than coercing a `MappedParent` into a
387 /// `MappedRelation`: a parent is not a relationship, and the first cut did
388 /// exactly that with an empty `field` and an empty `value` on the remote arm
389 /// — which reads as though something were being sent when nothing is. The
390 /// coercion compiled, produced the right output, and was wrong in a way only
391 /// reading the type would have caught.
392 fn note_parent(&mut self, mapped: MappedParent, out: &mut dyn Write) -> std::io::Result<()> {
393 if let MappedParent::Unsupported(diagnostic) = mapped {
394 writeln!(out, "{diagnostic}")?;
395 self.diagnostics.push(diagnostic);
396 }
397 Ok(())
398 }
399
400 /// The same, for a **project association**.
401 ///
402 /// Its own method for the reason [`SyncReport::note_parent`] gives: a project
403 /// membership is not a relationship, and coercing a `MappedProject` into a
404 /// `MappedRelation` would put a project id where a relationship id belongs.
405 /// The three methods share the diagnostic type and the count, which is the
406 /// vocabulary that matters.
407 fn note_project(&mut self, mapped: MappedProject, out: &mut dyn Write) -> std::io::Result<()> {
408 if let MappedProject::Unsupported(diagnostic) = mapped {
409 writeln!(out, "{diagnostic}")?;
410 self.diagnostics.push(diagnostic);
411 }
412 Ok(())
413 }
414
415 /// The same, for an **epic reference** (WP08, mission
416 /// `epics-work-items-01M3BYW4`).
417 ///
418 /// Its own method for the reason [`SyncReport::note_project`] gives: an epic
419 /// reference is not a relationship, and coercing a `MappedEpic` into a
420 /// `MappedRelation` would put an epic id where a relationship id belongs. The
421 /// four methods share the diagnostic type and the count, which is the vocabulary
422 /// that matters.
423 fn note_epic(&mut self, mapped: MappedEpic, out: &mut dyn Write) -> std::io::Result<()> {
424 if let MappedEpic::Unsupported(diagnostic) = mapped {
425 writeln!(out, "{diagnostic}")?;
426 self.diagnostics.push(diagnostic);
427 }
428 Ok(())
429 }
430}
431
432/// The record emitted when a remote tracker cannot hold a piece of local data —
433/// a relation (FR-005, C-005, `data-model.md` §6), a parent reference, a health
434/// value, a project association (WP08, mission `first-class-projects-01M3BYBW`),
435/// or an epic reference (WP08, mission `epics-work-items-01M3BYW4`).
436///
437/// The four fields are fixed by the data model. `field` is the important one: it
438/// is what makes the diagnostic actionable rather than a shrug, and
439/// `data-model.md` §6 requires it for exactly that reason — "so the message can
440/// name it".
441///
442/// One type for all of them because the vocabulary matters more than the noun: a
443/// reader of `tix export` output learns one shape ("tool: field cannot represent
444/// this local data (subject) — reason") and does not have to recognise which kind
445/// of local data each line is about. The **subject** in `relation_id` is whatever
446/// the diagnostic is about — a relationship id, an `issue->parent` pair, a health
447/// scope, an `issue->project` pair, or an `issue->epic` pair.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct RemoteMappingDiagnostic {
450 /// The local item the diagnostic is about: a relationship id, an
451 /// `issue->parent` pair, a health scope, or an `issue->project` pair.
452 pub relation_id: String,
453 /// The specific unsupported remote field or relation name.
454 pub field: String,
455 /// Which adapter produced the diagnostic.
456 pub tool: Tool,
457 /// Human-readable text, safe for CLI output, the TUI, and lint.
458 pub message: String,
459}
460
461impl std::fmt::Display for RemoteMappingDiagnostic {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 // "this local data", not "this relationship": the type now carries project
464 // associations and health values too, and a line that called an association
465 // a relationship would send the reader looking for an edge that is not there.
466 write!(
467 f,
468 "{}: {} cannot represent this local data ({}) — {}",
469 self.tool.as_str(),
470 self.field,
471 self.relation_id,
472 self.message
473 )
474 }
475}
476
477/// The outcome of mapping one local relationship for one tracker: either a
478/// remote representation, or a diagnostic naming why there is none.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub enum MappedRelation {
481 /// The remote representation to send: the relation `field` (the remote
482 /// relation kind, e.g. `blocks`) and the `value` the adapter needs. For a
483 /// relation verb taking two endpoints (Linear's `relations add`) the `value`
484 /// is the **target's remote identifier** (`ENG-42`); a tracker whose verb
485 /// carries the target inside a field renders its own value there.
486 Remote { field: String, value: String },
487 /// The tracker cannot express this relation. The **local** relation is
488 /// untouched — C-005 is that a relation the remote cannot hold still survives.
489 Unsupported(RemoteMappingDiagnostic),
490}
491
492/// The remote fields each tracker can express for a typed relation, and the one it
493/// maps every blocking edge onto.
494///
495/// This is the whole of the representability decision, and it is deliberately a
496/// table keyed on `(Tool, RelationKind)` rather than logic spread across the two
497/// adapters. Two consequences worth stating:
498///
499/// - **It is derived from what the adapters can actually send**, not from a guess
500/// about their APIs. `linear-cli relations add -r <kind> <FROM> <TO>` is the only
501/// relation verb either CLI exposes, so `blocks` maps for Linear and nothing else
502/// does; `glab` exposes no relation verb at all (see `RELATION_CLI_ABSENT`).
503/// - **`blocks` maps; `related`, `duplicates`, and `blocked_by` do not.** Both
504/// trackers' issue-update CLIs carry a title, body, labels, assignee, and state —
505/// and no field for "this issue is related to that one". Guessing otherwise
506/// would mean writing a title into a field, which is not a mapping.
507///
508/// `blocked_by` is the inverse of `blocks`, not a third thing, so it is reported
509/// against the same remote field: naming `blocking` for it would be true but would
510/// send the reader looking for a relation the tracker does not have.
511///
512/// ## Linear is wire-verified; GitLab is verified to have no command at all
513///
514/// The mapping knows what each tracker's *model* can express — GitLab and Linear
515/// both have blocking relations in their APIs — and the adapters drive a **CLI**,
516/// so what matters is whether the CLI exposes a verb.
517///
518/// **Linear's does.** `linear-cli` 0.3.28 (nesszer/linear-cli, the
519/// `cargo:linear-cli` crate) has `relations add --relation
520/// <blocks|blocked-by|related|duplicate> <FROM> <TO>`, checked against the
521/// installed binary on 2026-10-09. Four shapes parse — they fail only with
522/// `No workspace selected` (exit 1, no workspace configured here):
523/// `relations add -r blocks ENG-1 ENG-2`, `issues create "T" -t ENG -d D -p 2 -s
524/// Todo -o json`, `issues update ENG-1 -T T -d D -s Done -o json`, and
525/// `issues list -t ENG -s Todo -o json`; and two clap errors confirm the shape —
526/// `linear-cli issue create "T"` (exit 2, `unrecognized subcommand 'issue'`) and
527/// `linear-cli issues create --title T` (exit 2, `unexpected argument
528/// '--title'`). The API path itself is unverified (no workspace), so the
529/// adapter is checked at the grammar level and end to end against the fake.
530///
531/// Sending an unverified command line is not a conservative default; it is a
532/// production failure with a confusing message. So the seam maps, and an adapter
533/// sends only what a verified verb carries; **GitLab remains on the separate,
534/// permanent `RELATION_CLI_ABSENT` list** below — the two answers must not blur.
535///
536/// The follow-up this closes: verify the verb against the real CLI, add the tool
537/// to this list, and implement its `set_relations` ([`Tool::Linear`] does).
538const RELATION_WIRE_VERIFIED: &[Tool] = &[Tool::Linear];
539
540/// Tools whose CLI is **verified to expose no relation command at all**.
541///
542/// This is a *different, permanent* answer from "not yet verified", and the two
543/// must not share wording. A reader told "the verb is not verified" will reasonably
544/// wait for (or go looking for) the missing command; a reader told the CLI has no
545/// such command can stop.
546///
547/// `glab` 1.119.0, checked against the installed binary (tix #1354):
548/// `glab issue update --help` lists no `--blocking` (or any relation) flag, and
549/// `glab issue` has no relation/`link` subcommand. GitLab's *model* has issue links
550/// and the REST API exposes them, but the CLI this adapter drives does not.
551const RELATION_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
552
553// ---------------------------------------------------------------------------
554// WP08 — the parent reference across a sync round trip
555// ---------------------------------------------------------------------------
556
557/// The CLI verbs that would carry a parent reference, per adapter.
558///
559/// **Empty: no adapter has a verified parent verb.** Both trackers' *models* have
560/// a hierarchy — GitLab has epics and child issues, Linear has parent issues — but
561/// the adapters drive a CLI, and whether `glab issue create --parent` or
562/// `linear-cli issues create --parent` exists is a claim about a program this
563/// repository cannot run. (Linear's `relations add` is verified for *relations*
564/// and is on `RELATION_WIRE_VERIFIED`; that is a different verb and says nothing
565/// about a parent, and the fake in `tests/bin/` accepts no parent flag.)
566///
567/// So the seam maps, and nothing is sent until a verb is verified. What *does*
568/// work today — and is what the round trip covers — is that the reference
569/// survives: the local parent is retained, the mapping is reported, and an import
570/// restores a parent that came from the remote. Flipping a tool on here is a
571/// one-line change plus its adapter implementation, once the verb is confirmed by
572/// hand.
573const PARENT_WIRE_VERIFIED: &[Tool] = &[];
574
575/// The remote field each adapter would carry a parent in, if its verb existed.
576///
577/// Present so the diagnostic can *name* the thing that is missing, rather than
578/// saying only that something is. A reader told "GitLab cannot express this" will
579/// not go looking for the flag.
580const GITLAB_PARENT_FIELD: &str = "parent";
581const LINEAR_PARENT_FIELD: &str = "parent";
582
583/// What one parent reference maps to on the wire.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub enum MappedParent {
586 /// The remote value to send, as a field/value pair like a relation.
587 Remote { field: String, value: String },
588 /// The adapter cannot carry it. The **local** reference is untouched, which is
589 /// the same contract the relationship mapper follows (C-005): a reference the
590 /// remote cannot hold still survives here.
591 Unsupported(RemoteMappingDiagnostic),
592}
593
594/// Map one local parent reference to its remote representation, or say why not.
595///
596/// Pure: no adapter, no I/O, no configuration, and shared by both trackers so a
597/// future one cannot be added with a divergent contract (T033).
598#[must_use]
599pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
600 let Some(parent) = parent else {
601 // No reference is not a mapping problem. Reporting it would put a
602 // diagnostic on every parentless issue in the repository, which is every
603 // diagnostic being ignored.
604 return MappedParent::Remote {
605 field: parent_field(tool).to_string(),
606 value: String::new(),
607 };
608 };
609 let field = parent_field(tool);
610 if PARENT_WIRE_VERIFIED.contains(&tool) {
611 return MappedParent::Remote {
612 field: field.to_string(),
613 value: format!("#{parent}"),
614 };
615 }
616 MappedParent::Unsupported(RemoteMappingDiagnostic {
617 relation_id: format!("{issue_number}->{parent}"),
618 field: field.to_string(),
619 tool,
620 message: format!(
621 "#{issue_number}'s parent #{parent} is expressible in {}'s model as \
622 `{field}`, but the CLI verb that would carry it is not verified, so \
623 nothing is sent; the local reference is kept",
624 tool.as_str()
625 ),
626 })
627}
628
629/// The field an adapter would carry a parent in.
630fn parent_field(tool: Tool) -> &'static str {
631 match tool {
632 Tool::GitLab => GITLAB_PARENT_FIELD,
633 Tool::Linear => LINEAR_PARENT_FIELD,
634 }
635}
636
637// ---------------------------------------------------------------------------
638// WP08 (mission first-class-projects-01M3BYBW) — project associations across a
639// sync round trip
640// ---------------------------------------------------------------------------
641//
642// ## The two directions are not symmetric, and each says so
643//
644// **Outward: nothing leaves the machine, ever.** GitLab's and Linear's *models*
645// both know what a project is; the CLIs these adapters drive do not. `glab`
646// already passes the one project an issue lives in (`--project <path>`) on every
647// call and exposes no membership field at all, and Linear's issue carries a
648// single `project` — so neither can hold the *list* this mission's C-003
649// requires. Multi-membership is the intake's named risk, and the contract's
650// answer for it is C-005, not a guess: every association diagnoses, and the local
651// association list is untouched. Sending one member of a list would be a silent
652// drop with extra steps.
653//
654// **Inward: a payload that reports associations is honoured.** The read direction
655// is deliberately *not* gated the way the relation mapper's is, and the reason is
656// not convenience. `map_relation_from_remote` must refuse an unrecognised field
657// because turning remote text into a local edge can invent a relationship no
658// export ever made. A project id cannot: it is matched against the local
659// **registry** before anything is written, and an id the registry does not know
660// is refused with a diagnostic rather than written (C-005). So the worst an
661// invented id can produce is a named diagnostic, never local data. Reading a
662// field a payload reported is also not the same claim as sending one — the same
663// distinction `GlabIssue::parent` already draws.
664//
665// This is what makes the round trip non-vacuous: the export says what cannot
666// leave, the import applies what came back through the project **service**, and
667// the association list is byte-stable either way (NFR-002).
668
669/// A project reference a remote reported on one issue.
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct RemoteProject {
672 /// The remote's project identity. It is the **only** thing an import matches
673 /// on (C-002): an id is allocated once and never changes, so a remote that
674 /// renames a project still names the same one.
675 pub id: String,
676 /// The remote's display name for the project, when it sent one.
677 ///
678 /// Carried for the diagnostics — a reader recognises "the remote calls it
679 /// *Platform hardening*" — and **never** matched on. The local display name
680 /// stays the registry's (C-002), so a remote rename cannot change it, and it
681 /// is the only remote text in a diagnostic, which is why that diagnostic is
682 /// scrubbed.
683 pub name: Option<String>,
684}
685
686/// Parse the project references one remote issue reported.
687///
688/// **Pure and total**, in the shape [`crate::sync::mod`'s parent parser] is: an
689/// unrecognised payload yields no references — which the import then reports
690/// nothing about — rather than a deserialisation failure that aborts the whole
691/// list. Accepted, for both trackers, because the field's exact shape is a claim
692/// about a program this repository cannot run:
693///
694/// - an array of ids (`"projects": ["p-1", …]`) or of objects;
695/// - a single id, or a single object (`"project": {"id": …}`);
696/// - an object's id under `id`, `identifier`, `key`, or `project_id`, as a
697/// string or a number (`iid`-style numeric ids are real on the wire);
698/// - its name under `name`, `title`, or `display_name`, as a string.
699///
700/// **Entry order is preserved**, which is what keeps a round trip byte-stable
701/// (NFR-002): reordering here would reorder the local list.
702///
703/// An entry with a name but no id is **kept** with an empty id, so the import can
704/// report it by name instead of dropping it in silence; an entry with neither is
705/// not a reference at all and yields nothing.
706#[must_use]
707pub fn remote_projects(value: Option<&serde_json::Value>) -> Vec<RemoteProject> {
708 fn id_of(value: &serde_json::Value) -> String {
709 match value {
710 serde_json::Value::String(s) => s.clone(),
711 serde_json::Value::Number(n) => n.to_string(),
712 _ => String::new(),
713 }
714 }
715 fn one(value: &serde_json::Value) -> Option<RemoteProject> {
716 match value {
717 serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteProject {
718 id: id_of(value),
719 name: None,
720 }),
721 serde_json::Value::Object(map) => {
722 let id = ["id", "identifier", "key", "project_id"]
723 .iter()
724 .find_map(|key| map.get(*key))
725 .map(id_of)
726 .unwrap_or_default();
727 let name = ["name", "title", "display_name"]
728 .iter()
729 .find_map(|key| map.get(*key))
730 .and_then(serde_json::Value::as_str)
731 .map(str::to_string);
732 if id.trim().is_empty() && name.is_none() {
733 return None;
734 }
735 Some(RemoteProject { id, name })
736 }
737 _ => None,
738 }
739 }
740 let Some(value) = value else {
741 return Vec::new();
742 };
743 match value {
744 serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
745 other => one(other).into_iter().collect(),
746 }
747}
748
749/// The remote field each adapter would carry an issue's project associations in.
750///
751/// The two differ because the trackers' models differ, and the diagnostic names
752/// the field the reader should stop looking for:
753///
754/// - **GitLab** — `projects`. An issue JSON carries the one project that owns it;
755/// the field a *membership list* would need is `projects`, which no `glab`
756/// command exposes.
757/// - **Linear** — `project`. Its issue carries exactly one project, so the field
758/// that exists is named and the list it cannot hold is what diagnoses.
759const GITLAB_PROJECT_FIELD: &str = "projects";
760const LINEAR_PROJECT_FIELD: &str = "project";
761
762/// The field an adapter would carry project associations in.
763fn project_field(tool: Tool) -> &'static str {
764 match tool {
765 Tool::GitLab => GITLAB_PROJECT_FIELD,
766 Tool::Linear => LINEAR_PROJECT_FIELD,
767 }
768}
769
770/// Tools verified to drive project **membership** from their CLI.
771///
772/// **Empty, and not because the verb is merely unverified.** The relation and
773/// parent tables are empty pending a verb confirmation; this one is empty because
774/// neither CLI has a verb to confirm: `glab issue update --help` (1.119.0) lists
775/// `--project` only as *which project owns the issue*, and Linear's issue carries
776/// one `project` with no list form. So an issue with three projects cannot be
777/// expressed as three of anything, and C-003's multi-membership — the intake's
778/// named risk — is what makes this terminal rather than pending.
779///
780/// The table still exists rather than a bare `unsupported` constant so the seam
781/// has the same shape as the other three, and so a future tracker that *does*
782/// model membership in its CLI is a one-line addition plus its `set_projects`.
783const PROJECT_WIRE_VERIFIED: &[Tool] = &[];
784
785/// What one local project association maps to on the wire.
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub enum MappedProject {
788 /// The remote value to send, as a field/value pair like a relation.
789 Remote { field: String, value: String },
790 /// The adapter cannot carry it. The **local** association is untouched — C-005
791 /// is that a membership the remote cannot hold still survives.
792 Unsupported(RemoteMappingDiagnostic),
793}
794
795/// Map one local project association to its remote representation, or say why not.
796///
797/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
798/// one cannot be added with a divergent contract — and the representability
799/// decision lives in exactly one place instead of once per adapter.
800///
801/// `issue_number` appears in the message because the diagnostic has to be
802/// actionable: the association list can be long, and "project `p-3` cannot be
803/// sent" without the issue is a shrug.
804#[must_use]
805pub fn map_project_to_remote(tool: Tool, issue_number: u32, project_id: &str) -> MappedProject {
806 let field = project_field(tool);
807 let id = project_id.trim();
808 if id.is_empty() {
809 // Not a representability question: there is nothing to send, and an empty
810 // entry in the list is a repair job rather than a remote capability gap.
811 return MappedProject::Unsupported(RemoteMappingDiagnostic {
812 relation_id: format!("{issue_number}->"),
813 field: field.to_string(),
814 tool,
815 message: format!(
816 "issue #{issue_number} carries a project association with no id, so there \
817 is nothing to send; the entry is kept locally — dissociating the empty \
818 reference, or editing the issue's project list, clears it"
819 ),
820 });
821 }
822 if PROJECT_WIRE_VERIFIED.contains(&tool) {
823 return MappedProject::Remote {
824 field: field.to_string(),
825 value: id.to_string(),
826 };
827 }
828 MappedProject::Unsupported(RemoteMappingDiagnostic {
829 relation_id: format!("{issue_number}->{id}"),
830 field: field.to_string(),
831 tool,
832 message: format!(
833 "#{issue_number}'s membership of project `{id}` has no {} field to travel in \
834 — {} — so nothing is sent and the local association is kept",
835 tool.as_str(),
836 project_absence_reason(tool)
837 ),
838 })
839}
840
841/// Why a tracker's CLI cannot carry a project membership, per tool.
842///
843/// A **permanent** claim about the CLI the adapter drives, not "not yet
844/// verified", and the wording says so for the same reason `RELATION_CLI_ABSENT`
845/// does: a reader told "the verb is unverified" will wait for it, and there is
846/// nothing to wait for. The two reasons differ, so the message does.
847fn project_absence_reason(tool: Tool) -> &'static str {
848 match tool {
849 Tool::GitLab => {
850 "`glab` names one owning project (`--project`) and exposes no membership field"
851 }
852 Tool::Linear => {
853 "Linear's issue carries one `project` and no list, so a second \
854 membership has nowhere to go"
855 }
856 }
857}
858
859/// Map one remote project reference back onto a local association id, or say why
860/// not.
861///
862/// The reverse value is **local-shaped**, like [`map_relation_from_remote`]'s: the
863/// frontmatter key the association lives under ([`crate::projects::PROJECTS_KEY`])
864/// and the id, not a copy of the tool's field name — echoing the field back would
865/// make the same association read differently per tracker, which is the opposite
866/// of a shared seam.
867///
868/// Not gated on [`PROJECT_WIRE_VERIFIED`], and the module header records why: an
869/// id is validated against the local registry before anything is written, so this
870/// direction cannot invent local data the way an unverified relation field could.
871#[must_use]
872pub fn map_project_from_remote(tool: Tool, remote: &RemoteProject) -> MappedProject {
873 let id = remote.id.trim();
874 if id.is_empty() {
875 return MappedProject::Unsupported(RemoteMappingDiagnostic {
876 relation_id: remote.name.clone().unwrap_or_default(),
877 field: project_field(tool).to_string(),
878 tool,
879 message: format!(
880 "the remote reported a project it named `{}` but gave no id, and an id is \
881 the only thing a local association can match on; nothing was associated \
882 and the local list is unchanged",
883 remote.name.as_deref().unwrap_or("")
884 ),
885 });
886 }
887 MappedProject::Remote {
888 field: crate::projects::PROJECTS_KEY.to_string(),
889 value: id.to_string(),
890 }
891}
892
893/// Redact the resolved secret from a diagnostic's message.
894///
895/// The diagnostics this module builds itself are made of local ids and field
896/// names. The **import** builds four — a reference it cannot read, a project the
897/// registry does not declare, an epic the registry does not declare, and a service
898/// refusal — and the first three quote the name a remote payload reported. Remote
899/// output is the one input that can carry a credential, which is why the import's
900/// diagnostics go through here, using the same [`scrub_secret`] the adapters use on
901/// their errors.
902fn scrub_diagnostic(diagnostic: &mut RemoteMappingDiagnostic, secret: Option<&str>) {
903 if let Some(secret) = secret {
904 diagnostic.message = scrub_secret(&diagnostic.message, secret);
905 diagnostic.relation_id = scrub_secret(&diagnostic.relation_id, secret);
906 }
907}
908
909// ---------------------------------------------------------------------------
910// WP08 (mission epics-work-items-01M3BYW4) — epic references across a sync
911// round trip
912// ---------------------------------------------------------------------------
913//
914// ## What the adapters can actually express
915//
916// **Outward: nothing leaves the machine, and that is the derived answer, not a
917// placeholder.** Research R7 states GitLab's adapter has no epic concept and the
918// `grep` confirms it: neither `glab` nor `linear-cli` carries an epic membership
919// list. `glab`'s issue JSON names at most one epic (`epic_iid`, which this adapter
920// already reads as the issue's single parent) and exposes no flag to send one;
921// Linear has no epic level at all. Because the mission allows **several** epics per
922// issue (C-003), an adapter that sent one member of a list would be a silent drop
923// with extra steps — which C-007 forbids outright. So every reference diagnoses and
924// the local list is untouched.
925//
926// **Inward: a payload that reports epic ids is honoured.** Like the project seam,
927// the read direction is deliberately *not* gated: the id is matched against the
928// local **registry** before anything is written, so the worst an invented id can
929// produce is a named diagnostic, never local data (C-007).
930//
931// This is what makes the round trip non-vacuous: the export says what cannot leave,
932// the import applies what came back through the epic **service**
933// ([`Store::reference_epic`], the same path `tix edit --add-epic` takes), and the
934// reference list is byte-stable either way (NFR-002).
935
936/// An epic reference a remote reported on one issue.
937#[derive(Debug, Clone, PartialEq, Eq)]
938pub struct RemoteEpic {
939 /// The remote's epic identity. It is the **only** thing an import matches on
940 /// (C-003): an epic id is allocated once and never changes, so a remote that
941 /// renames an epic still names the same one.
942 pub id: String,
943 /// The remote's display name for the epic, when it sent one.
944 ///
945 /// Carried for the diagnostics — a reader recognises "the remote calls it
946 /// *Sync hardening*" — and **never** matched on. The local display name stays
947 /// the registry's (C-003), so a remote rename cannot change it; it is the only
948 /// remote text in a diagnostic, which is why that diagnostic is scrubbed.
949 pub name: Option<String>,
950}
951
952/// Parse the epic references one remote issue reported.
953///
954/// **Pure and total**, in the shape [`remote_projects`] is: an unrecognised payload
955/// yields no references — which the import then reports nothing about — rather than
956/// a deserialisation failure that aborts the whole list. Accepted, for both
957/// trackers, because the field's exact shape is a claim about a program this
958/// repository cannot run:
959///
960/// - an array of ids (`"epics": ["e-1", …]`) or of objects;
961/// - a single id, or a single object (`"epic": {"id": …}`);
962/// - an object's id under `id`, `identifier`, `key`, or `epic_id`, as a string or a
963/// number (numeric ids are real on the wire);
964/// - its name under `name`, `title`, or `display_name`, as a string.
965///
966/// **Entry order is preserved**, which is what keeps a round trip byte-stable
967/// (NFR-002): reordering here would reorder the local list. An entry with a name
968/// but no id is **kept** with an empty id, so the import can report it by name
969/// instead of dropping it in silence; an entry with neither is not a reference at
970/// all and yields nothing.
971#[must_use]
972pub fn remote_epics(value: Option<&serde_json::Value>) -> Vec<RemoteEpic> {
973 fn id_of(value: &serde_json::Value) -> String {
974 match value {
975 serde_json::Value::String(s) => s.clone(),
976 serde_json::Value::Number(n) => n.to_string(),
977 _ => String::new(),
978 }
979 }
980 fn one(value: &serde_json::Value) -> Option<RemoteEpic> {
981 match value {
982 serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteEpic {
983 id: id_of(value),
984 name: None,
985 }),
986 serde_json::Value::Object(map) => {
987 let id = ["id", "identifier", "key", "epic_id"]
988 .iter()
989 .find_map(|key| map.get(*key))
990 .map(id_of)
991 .unwrap_or_default();
992 let name = ["name", "title", "display_name"]
993 .iter()
994 .find_map(|key| map.get(*key))
995 .and_then(serde_json::Value::as_str)
996 .map(str::to_string);
997 if id.trim().is_empty() && name.is_none() {
998 return None;
999 }
1000 Some(RemoteEpic { id, name })
1001 }
1002 _ => None,
1003 }
1004 }
1005 let Some(value) = value else {
1006 return Vec::new();
1007 };
1008 match value {
1009 serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
1010 other => one(other).into_iter().collect(),
1011 }
1012}
1013
1014/// The remote field an adapter would carry an issue's epic references in.
1015///
1016/// **One constant, not a per-tool table.** Unlike a project association — where
1017/// the two models genuinely name different fields (`projects` for GitLab's owning
1018/// project vs Linear's single `project`) — both trackers would name an epic
1019/// membership list `epics`, so a per-tool indirection would carry no information.
1020/// The per-tool difference lives in [`epic_absence_reason`], which is where it
1021/// actually is: `glab` names at most one epic per issue (`epic_iid`), while Linear
1022/// has no epic level at all.
1023const EPIC_FIELD: &str = "epics";
1024
1025/// Tools verified to drive an epic **membership** from their CLI.
1026///
1027/// **Empty, and derived from the adapters rather than assumed.** `glab`'s issue
1028/// carries at most one epic (`epic_iid`) and exposes no flag to set it or to hold a
1029/// list; Linear has no epic level. So an issue in three epics cannot be expressed as
1030/// three of anything, and C-003's multi-membership is what makes this terminal
1031/// rather than pending — the same reasoning `PROJECT_WIRE_VERIFIED` records.
1032///
1033/// The table still exists rather than a bare `unsupported` constant so the seam has
1034/// the same shape as the other three, and so a future tracker that *does* model an
1035/// epic list in its CLI is a one-line addition plus its `set_epics`.
1036const EPIC_WIRE_VERIFIED: &[Tool] = &[];
1037
1038/// What one local epic reference maps to on the wire.
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub enum MappedEpic {
1041 /// The remote value to send, as a field/value pair like a relation.
1042 Remote { field: String, value: String },
1043 /// The adapter cannot carry it. The **local** reference is untouched — C-007 is
1044 /// that a reference the remote cannot hold still survives.
1045 Unsupported(RemoteMappingDiagnostic),
1046}
1047
1048/// Map one local epic reference to its remote representation, or say why not.
1049///
1050/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
1051/// one cannot be added with a divergent contract — and the representability decision
1052/// lives in exactly one place instead of once per adapter.
1053///
1054/// `issue_number` appears in the message because the diagnostic has to be
1055/// actionable: the reference list can be long, and "epic `e-3` cannot be sent"
1056/// without the issue is a shrug.
1057#[must_use]
1058pub fn map_epic_to_remote(tool: Tool, issue_number: u32, epic_id: &str) -> MappedEpic {
1059 let field = EPIC_FIELD;
1060 let id = epic_id.trim();
1061 if id.is_empty() {
1062 // Not a representability question: there is nothing to send, and an empty
1063 // entry in the list is a repair job rather than a remote capability gap.
1064 return MappedEpic::Unsupported(RemoteMappingDiagnostic {
1065 relation_id: format!("{issue_number}->"),
1066 field: field.to_string(),
1067 tool,
1068 message: format!(
1069 "issue #{issue_number} carries an epic reference with no id, so there is \
1070 nothing to send; the entry is kept locally — dereferencing the empty \
1071 reference, or editing the issue's epic list, clears it"
1072 ),
1073 });
1074 }
1075 if EPIC_WIRE_VERIFIED.contains(&tool) {
1076 return MappedEpic::Remote {
1077 field: field.to_string(),
1078 value: id.to_string(),
1079 };
1080 }
1081 MappedEpic::Unsupported(RemoteMappingDiagnostic {
1082 relation_id: format!("{issue_number}->{id}"),
1083 field: field.to_string(),
1084 tool,
1085 message: format!(
1086 "#{issue_number}'s reference to epic `{id}` has no {} field to travel in \
1087 — {} — so nothing is sent and the local reference is kept",
1088 tool.as_str(),
1089 epic_absence_reason(tool)
1090 ),
1091 })
1092}
1093
1094/// Why a tracker's CLI cannot carry an epic membership, per tool.
1095///
1096/// A **permanent** claim about the CLI the adapter drives, not "not yet verified",
1097/// and the wording says so for the same reason `PROJECT_WIRE_VERIFIED`'s reason
1098/// does: a reader told "the verb is unverified" will wait for it, and there is
1099/// nothing to wait for. The two reasons differ, so the message does.
1100fn epic_absence_reason(tool: Tool) -> &'static str {
1101 match tool {
1102 Tool::GitLab => {
1103 "`glab` names at most one epic per issue (`epic_iid`) and exposes no flag \
1104 to send one, so a membership list has nowhere to go"
1105 }
1106 Tool::Linear => {
1107 "Linear has no epic level at all — the nearest concept is a project — so an \
1108 epic reference has nowhere to go"
1109 }
1110 }
1111}
1112
1113/// Map one remote epic reference back onto a local reference id, or say why not.
1114///
1115/// The reverse value is **local-shaped**, like [`map_project_from_remote`]'s: the
1116/// frontmatter key the reference lives under ([`crate::epics::EPICS_KEY`]) and the
1117/// id, not a copy of the tool's field name — echoing the field back would make the
1118/// same reference read differently per tracker, which is the opposite of a shared
1119/// seam.
1120///
1121/// Not gated on [`EPIC_WIRE_VERIFIED`], and the module header records why: an id is
1122/// validated against the local registry before anything is written, so this
1123/// direction cannot invent local data the way an unverified relation field could.
1124#[must_use]
1125pub fn map_epic_from_remote(tool: Tool, remote: &RemoteEpic) -> MappedEpic {
1126 let id = remote.id.trim();
1127 if id.is_empty() {
1128 return MappedEpic::Unsupported(RemoteMappingDiagnostic {
1129 relation_id: remote.name.clone().unwrap_or_default(),
1130 field: EPIC_FIELD.to_string(),
1131 tool,
1132 message: format!(
1133 "the remote reported an epic it named `{}` but gave no id, and an id is \
1134 the only thing a local reference can match on; nothing was referenced \
1135 and the local list is unchanged",
1136 remote.name.as_deref().unwrap_or("")
1137 ),
1138 });
1139 }
1140 MappedEpic::Remote {
1141 field: crate::epics::EPICS_KEY.to_string(),
1142 value: id.to_string(),
1143 }
1144}
1145
1146/// The remote field GitLab's model carries an issue's health in.
1147///
1148/// Named so the diagnostic can say *what* is missing rather than only that something
1149/// is: a reader told "GitLab cannot carry this" will not go looking for the flag.
1150const GITLAB_HEALTH_FIELD: &str = "health_status";
1151
1152/// The remote field Linear's model would need for an issue's health. Linear has no
1153/// issue level health, so the constant names the field a future adapter would have to
1154/// introduce — and the mapping says which of the two reasons applies.
1155const LINEAR_HEALTH_FIELD: &str = "health";
1156
1157/// Tools whose **model has no field** for an issue's health at all.
1158///
1159/// The claim is different from "the verb is not verified", and the message says
1160/// which, for the same reason `RELATION_CLI_ABSENT` does: a reader who is told
1161/// "GitLab can express this but the verb is unverified" will look for the verb.
1162const HEALTH_MODEL_ABSENT: &[Tool] = &[Tool::Linear];
1163
1164/// Tools verified to drive the health field from their CLI.
1165///
1166/// Empty, and for the same reason `PARENT_WIRE_VERIFIED` is: whether `glab issue
1167/// create --health-status ...` exists is a claim about a program this repository
1168/// cannot run. The fakes in `tests/bin/` accept no health flag at all, which is
1169/// better evidence of the surface than an assumption made after it. Flipping a
1170/// tool on here is a one-line change plus its adapter implementation, once the
1171/// verb is confirmed by hand.
1172const HEALTH_WIRE_VERIFIED: &[Tool] = &[];
1173
1174/// What one local health value maps to on the wire.
1175#[derive(Debug, Clone, PartialEq, Eq)]
1176pub enum MappedHealth {
1177 /// The remote value to send, as a field/value pair like a relation.
1178 Remote { field: String, value: String },
1179 /// The adapter cannot carry it. The **local** value is untouched, which is the
1180 /// same contract the relationship and parent mappers follow (C-008): a value the
1181 /// remote cannot hold still survives here, and a remote that lacks the field can
1182 /// never clear it.
1183 Unsupported(RemoteMappingDiagnostic),
1184}
1185
1186/// Map one local health value to its remote representation, or say why not (T022,
1187/// T023).
1188///
1189/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
1190/// one cannot be added with a divergent contract, and the representability table
1191/// lives in exactly one place.
1192///
1193/// Only an **issue**-scoped value has a remote home: a rollup belongs to this
1194/// repository's memberships, and a remote that knew nothing of the parent would carry
1195/// a number the local side derived. A non-issue scope is therefore reported, never
1196/// silently sent as if it were the issue's own health.
1197#[must_use]
1198pub fn map_health_to_remote(
1199 tool: Tool,
1200 scope: &crate::health::HealthScope,
1201 state: crate::health::HealthState,
1202) -> MappedHealth {
1203 let field = health_field(tool);
1204 if HEALTH_MODEL_ABSENT.contains(&tool) {
1205 return MappedHealth::Unsupported(RemoteMappingDiagnostic {
1206 relation_id: scope.as_str(),
1207 field: field.to_string(),
1208 tool,
1209 message: format!(
1210 "{}'s issue model has no health field, so `{}` stays local; a remote that \
1211 cannot carry the value never clears it",
1212 tool.as_str(),
1213 state.as_str()
1214 ),
1215 });
1216 }
1217 let crate::health::HealthScope::Issue(number) = scope else {
1218 return MappedHealth::Unsupported(RemoteMappingDiagnostic {
1219 relation_id: scope.as_str(),
1220 field: field.to_string(),
1221 tool,
1222 message: format!(
1223 "`{}` is a derived value for {}, which has no remote representation; only \
1224 an issue's own health can be sent, so the local value is kept",
1225 state.as_str(),
1226 scope
1227 ),
1228 });
1229 };
1230 if HEALTH_WIRE_VERIFIED.contains(&tool) {
1231 return MappedHealth::Remote {
1232 field: field.to_string(),
1233 value: state.as_str().to_string(),
1234 };
1235 }
1236 MappedHealth::Unsupported(RemoteMappingDiagnostic {
1237 relation_id: format!("issue:{number}"),
1238 field: field.to_string(),
1239 tool,
1240 message: format!(
1241 "issue #{number}'s `{}` is expressible in {}'s model as `{field}`, but the CLI \
1242 verb that would carry it is not verified, so nothing is sent; the local value \
1243 is kept",
1244 state.as_str(),
1245 tool.as_str()
1246 ),
1247 })
1248}
1249
1250/// The field an adapter carries an issue's health in.
1251fn health_field(tool: Tool) -> &'static str {
1252 match tool {
1253 Tool::GitLab => GITLAB_HEALTH_FIELD,
1254 Tool::Linear => LINEAR_HEALTH_FIELD,
1255 }
1256}
1257
1258// ---------------------------------------------------------------------------
1259// WP07 (mission roadmap-initiatives-01M3C8SX) — initiatives across a sync
1260// round trip
1261// ---------------------------------------------------------------------------
1262//
1263// ## What the adapters can actually express, checked rather than assumed
1264//
1265// An initiative is a portfolio-level record, and this module's plan is explicit
1266// that the sync side carries **unsupported-field diagnostics only** — no adapter
1267// gains an initiative sender here. The representability answer is still derived
1268// from the CLIs the adapters drive, and the two trackers' answers differ in
1269// *reason* even though both end at "nothing is sent":
1270//
1271// - **GitLab: the CLI has no initiative command at all.** `glab` 1.119.0 (checked
1272// against the installed binary on 2026-10-09) lists no `initiative` command:
1273// `glab --help` has `issue` and `milestone`, and its experimental `work-items`
1274// tree covers epics, issues and tasks. GitLab's *model* has no initiative either.
1275// That is a **permanent** answer, like [`RELATION_CLI_ABSENT`], and the wording
1276// says so: a reader told "the verb is unverified" would wait for one, and there
1277// is nothing to wait for.
1278// - **Linear: the verb exists, but it cannot hold the record.** `linear-cli` 0.3.28
1279// (checked the same day) *does* expose `initiatives list|get|create|update|delete`;
1280// `linear-cli initiatives list -o json` parses and fails only with `No workspace
1281// selected` (exit 1), which is a grammar-level verification of the command line.
1282// So the honest statement is not "there is no verb" — it is that the
1283// `create`/`update` verbs take only a **name**, a **description** and a
1284// **status**, so they cannot carry the local record's `owner`, `priority`,
1285// `labels`, `target_date`, or its typed project/epic/milestone associations.
1286// Sending the fields they *can* take while dropping the rest would be a silent
1287// drop with extra steps (C-005/C-007), so the whole local record is kept.
1288//
1289// `INITIATIVE_WIRE_VERIFIED` is empty, and `initiative_absence_reason` carries the
1290// per-tool difference so the two answers never blur. The table exists rather than a
1291// bare `unsupported` constant for the same reason the parent/health tables do: a
1292// future tracker that models the whole record in its CLI is a one-line addition plus
1293// its `set_initiatives`.
1294
1295/// The remote field an adapter would carry an initiative in.
1296///
1297/// Both trackers would name it `initiatives`, so — unlike a project association,
1298/// where the two models genuinely name different fields — a per-tool indirection
1299/// would carry no information. The per-tool difference lives in
1300/// [`initiative_absence_reason`], which is where it actually is.
1301const INITIATIVE_FIELD: &str = "initiatives";
1302
1303/// Tools whose CLI is **verified to expose no initiative command at all**.
1304///
1305/// A *permanent* answer, distinct from "not yet verified", for the same reason
1306/// [`RELATION_CLI_ABSENT`] is: a reader told "the verb is unverified" will reasonably
1307/// wait for it, and `glab` has no such command to wait for.
1308const INITIATIVE_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
1309
1310/// Tools verified to drive an initiative from their CLI.
1311///
1312/// **Empty**, and deliberately so on both trackers. No adapter implements a
1313/// `set_initiatives`, and the one CLI that exposes initiative verbs
1314/// (`linear-cli`) cannot carry the record's fields — see the section header. The
1315/// table is the same shape as the other four so a future tracker that *does* model
1316/// the whole record is a one-line addition plus its adapter method.
1317const INITIATIVE_WIRE_VERIFIED: &[Tool] = &[];
1318
1319/// What one local initiative maps to on the wire.
1320#[derive(Debug, Clone, PartialEq, Eq)]
1321pub enum MappedInitiative {
1322 /// The remote value to send, as a field/value pair like a relation. A verified
1323 /// adapter would put the **remote reference** in `value`; until one exists this
1324 /// arm is unreachable, exactly as [`MappedParent::Remote`] is.
1325 Remote { field: String, value: String },
1326 /// The target cannot carry the record. The **local** record is untouched,
1327 /// which is the contract every other mapper follows (C-005): a record the
1328 /// remote cannot hold still survives, and a remote that lacks the field can
1329 /// never clear it.
1330 Unsupported(RemoteMappingDiagnostic),
1331}
1332
1333/// Map one local initiative to its remote representation, or say why not (T034,
1334/// T035).
1335///
1336/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
1337/// one cannot be added with a divergent contract — and the representability
1338/// decision lives in exactly one place instead of once per adapter.
1339///
1340/// `initiative_id` is the **stable** id (C-002), never a display name: it is the
1341/// subject the diagnostic names, and the value a verified adapter would send.
1342#[must_use]
1343pub fn map_initiative_to_remote(tool: Tool, initiative_id: &str) -> MappedInitiative {
1344 let field = INITIATIVE_FIELD;
1345 if INITIATIVE_WIRE_VERIFIED.contains(&tool) {
1346 return MappedInitiative::Remote {
1347 field: field.to_string(),
1348 value: initiative_id.to_string(),
1349 };
1350 }
1351 MappedInitiative::Unsupported(RemoteMappingDiagnostic {
1352 relation_id: initiative_id.to_string(),
1353 field: field.to_string(),
1354 tool,
1355 message: initiative_absence_reason(tool).to_string(),
1356 })
1357}
1358
1359/// Why a tracker's CLI cannot carry a local initiative, per tool.
1360///
1361/// The two reasons differ and the message must not blur them: GitLab's is a
1362/// **permanent** "the CLI has no such command", while Linear's is "the verb exists
1363/// but cannot hold the record's fields". A reader told the wrong one would either
1364/// wait for a command that will never appear or look for a flag that is not there.
1365fn initiative_absence_reason(tool: Tool) -> &'static str {
1366 if INITIATIVE_CLI_ABSENT.contains(&tool) {
1367 return "the CLI exposes no initiative command at all, so the whole local record is \
1368 kept; a remote that cannot hold an initiative never clears one";
1369 }
1370 "the CLI's `initiatives` verbs take only a name, a description and a status — they \
1371 cannot carry `owner`, `priority`, `labels`, `target_date`, or the typed \
1372 project/epic/milestone associations — so sending those three fields would drop the \
1373 rest; the whole local record is kept instead"
1374}
1375
1376const GITLAB_BLOCKING_FIELD: &str = "blocking";
1377const GITLAB_BLOCKING_VALUE: &str = "blocks";
1378const LINEAR_BLOCKING_FIELD: &str = "blocks";
1379const LINEAR_BLOCKING_VALUE: &str = "blocks";
1380
1381/// Map one local relationship to its remote representation, or say why not.
1382///
1383/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
1384/// one cannot be added with a divergent contract (T033) — and the representability
1385/// table lives in exactly one place instead of once per adapter.
1386///
1387/// `target` is the **target issue's remote reference**, if it is known: Linear's
1388/// `relations add` needs the target's remote identifier (`ENG-42`), and an edge
1389/// whose target has not been exported yet cannot be sent. Passing `None` therefore
1390/// yields a diagnostic that names the target and says so — never a guessed
1391/// identifier (C-005: the local edge survives). Trackers whose verb does not need a
1392/// second endpoint (or none of whose verbs are verified) ignore `target`.
1393#[must_use]
1394pub fn map_relation_to_remote(
1395 tool: Tool,
1396 relation: &crate::relationships::Relationship,
1397 target: Option<&str>,
1398) -> MappedRelation {
1399 use crate::relationships::RelationKind;
1400 let (field, value) = match tool {
1401 Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
1402 Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
1403 };
1404 // The tracker's *model* can express a blocking edge — and the adapter drives a
1405 // CLI whose verb is verified only for the tools on `RELATION_WIRE_VERIFIED`.
1406 let model_supports = matches!(
1407 relation.kind,
1408 RelationKind::Blocks | RelationKind::BlockedBy
1409 );
1410 if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
1411 let value = match tool {
1412 // Linear's `relations add` takes the target's **remote identifier** as
1413 // its second endpoint. Without one there is nothing to send, and a
1414 // guessed identifier would be worse than a diagnostic.
1415 Tool::Linear => match target {
1416 Some(target) => target.to_string(),
1417 None => {
1418 return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1419 relation_id: relation.id.clone(),
1420 field: field.to_string(),
1421 tool,
1422 message: format!(
1423 "`{}` targets #{}, which has not been exported yet, so its \
1424 remote identifier is unknown and the edge cannot be sent; \
1425 the local edge is kept",
1426 relation.kind.as_str(),
1427 relation.to
1428 ),
1429 });
1430 }
1431 },
1432 // GitLab is never on `RELATION_WIRE_VERIFIED` (its CLI has no relation
1433 // command at all), so this arm is unreachable today; its rendering is
1434 // left exactly as it was rather than "fixed" into an unverified shape.
1435 Tool::GitLab => format!("{value} #{}", relation.to),
1436 };
1437 return MappedRelation::Remote {
1438 field: field.to_string(),
1439 value,
1440 };
1441 }
1442 if model_supports {
1443 // Expressible in the model, not drivable from the CLI. A *different* claim
1444 // from "unsupported", and the message says which — a reader who is told
1445 // "GitLab cannot express this" will not go looking for the verb. And for a
1446 // CLI verified to have no relation command at all, the message must not say
1447 // "not verified": there is nothing to verify (tix #1354).
1448 let reason = if RELATION_CLI_ABSENT.contains(&tool) {
1449 format!(
1450 "the {} CLI exposes no issue-relation command, so it cannot be sent",
1451 tool.as_str()
1452 )
1453 } else {
1454 "the CLI verb that would carry it is not verified, so nothing is sent".to_string()
1455 };
1456 return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1457 relation_id: relation.id.clone(),
1458 field: field.to_string(),
1459 tool,
1460 message: format!(
1461 "`{}` is expressible in {}'s model as `{field}`, but {reason}; the local \
1462 edge is kept",
1463 relation.kind.as_str(),
1464 tool.as_str()
1465 ),
1466 });
1467 }
1468 MappedRelation::Unsupported(RemoteMappingDiagnostic {
1469 relation_id: relation.id.clone(),
1470 field: field.to_string(),
1471 tool,
1472 message: format!(
1473 "`{}` is a local-only relation; {} has no field for it, so the local \
1474 edge is kept and the remote one is not created",
1475 relation.kind.as_str(),
1476 tool.as_str()
1477 ),
1478 })
1479}
1480
1481/// The inverse: read a remote relation back to a local kind and target.
1482///
1483/// Symmetric with [`map_relation_to_remote`] by construction — the blocking field
1484/// maps back to `blocks`, and nothing else maps back at all. A remote payload
1485/// carrying a field this function does not recognise yields a diagnostic rather
1486/// than a default, so an import cannot invent a relation the export never made.
1487#[must_use]
1488pub fn map_relation_from_remote(
1489 tool: Tool,
1490 field: &str,
1491 value: &str,
1492 relation_id: &str,
1493) -> MappedRelation {
1494 let expected = match tool {
1495 Tool::GitLab => GITLAB_BLOCKING_FIELD,
1496 Tool::Linear => LINEAR_BLOCKING_FIELD,
1497 };
1498 // Gated the same way as the forward direction: without a verified verb there is
1499 // no payload this could have received, and coercing one would invent a
1500 // relationship the export never made.
1501 if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
1502 // The value this module writes is `"{kind} #{n}"`, so the target is the
1503 // **last** whitespace-separated token. Taking the first reads the kind word
1504 // and fails — which is what an earlier version did, and it made the round
1505 // trip look asymmetric when only the parser was.
1506 if let Some(target) = value
1507 .split_whitespace()
1508 .last()
1509 .and_then(|t| t.strip_prefix('#'))
1510 .and_then(|t| t.parse::<u32>().ok())
1511 {
1512 // The reverse value is **local**-shaped — the canonical kind spelling
1513 // and a `#`-prefixed number — not a copy of the remote field name.
1514 // GitLab's field is `blocking` and Linear's is `blocks`; echoing the
1515 // field back would make the round trip return different text per
1516 // tracker for the same relationship, which is the opposite of
1517 // symmetric.
1518 return MappedRelation::Remote {
1519 field: "kind".to_string(),
1520 value: format!("blocks #{target}"),
1521 };
1522 }
1523 return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1524 relation_id: relation_id.to_string(),
1525 field: field.to_string(),
1526 tool,
1527 message: format!(
1528 "`{value}` is not a target issue number, so the remote relation \
1529 cannot be read back; no local edge was created"
1530 ),
1531 });
1532 }
1533 MappedRelation::Unsupported(RemoteMappingDiagnostic {
1534 relation_id: relation_id.to_string(),
1535 field: field.to_string(),
1536 tool,
1537 message: format!(
1538 "`{field}` is not a relation field {} writes, so it is ignored; the \
1539 local relationship set is unchanged",
1540 tool.as_str()
1541 ),
1542 })
1543}
1544
1545/// Map a local column to the remote state/label sent to the tracker
1546/// (`contracts/external-tools.md` mapping table).
1547///
1548/// - GitLab: done → `closed`, anything else → `opened`.
1549/// - Linear: done → `Done`, otherwise the first non-done column label.
1550#[must_use]
1551pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
1552 let board = config.default_config();
1553 match tool {
1554 Tool::GitLab => {
1555 if is_done_column(board, local_status, config) {
1556 "closed".into()
1557 } else {
1558 "opened".into()
1559 }
1560 }
1561 Tool::Linear => {
1562 if is_done_column(board, local_status, config) {
1563 "Done".into()
1564 } else {
1565 first_work_column(board, config).to_string()
1566 }
1567 }
1568 }
1569}
1570
1571/// Map a remote state/label back onto a local column. Closed/Done maps to
1572/// the effective done column; anything else lands in the column whose name
1573/// matches (case-insensitive), else the first column — per the contract's
1574/// "whose name matches, else into the first column".
1575#[must_use]
1576pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
1577 let board = config.default_config();
1578 // WP07 T032: the *service's* completion column, not `done_column`. See
1579 // `completion_column` for why choosing one local column is this module's business
1580 // while the meaning of "complete" is not.
1581 let done = completion_column(board, config);
1582 match tool {
1583 Tool::GitLab => {
1584 if remote_status == "closed" {
1585 done
1586 } else {
1587 board
1588 .columns
1589 .first()
1590 .cloned()
1591 .unwrap_or_else(|| "todo".into())
1592 }
1593 }
1594 Tool::Linear => {
1595 if remote_status == "Done" {
1596 done
1597 } else {
1598 board
1599 .columns
1600 .iter()
1601 .find(|c| c.eq_ignore_ascii_case(remote_status))
1602 .cloned()
1603 .unwrap_or_else(|| {
1604 board
1605 .columns
1606 .first()
1607 .cloned()
1608 .unwrap_or_else(|| "todo".into())
1609 })
1610 }
1611 }
1612 }
1613}
1614
1615/// Whether `local_status` is the board's **completion** column, by the service.
1616///
1617/// WP07 T032. This was `board.effective_done_column() == Some(local_status)` — a
1618/// column-*name* comparison, which is a second local definition of "done" and exactly
1619/// what C-004 forbids. It agreed with the service on a board with no
1620/// `category_map` — every existing configuration — and disagreed on precisely the
1621/// boards this mission exists to support: a board mapping `shipped` to `completed`
1622/// exports `shipped` as *open* and leaves `done_column` (mapped to `active`) as
1623/// *closed*.
1624///
1625/// `sync/mod.rs` is out of this mission's map; the edit is the completion
1626/// reconciliation T032 names, and the rationale is recorded here and in
1627/// `decisions/WP07-verification.md`.
1628fn is_done_column(board: &BoardConfiguration, local_status: &str, config: &Config) -> bool {
1629 let resolution =
1630 crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
1631 resolution
1632 .resolve(local_status)
1633 .is_some_and(crate::workflow::WorkflowCategory::is_complete)
1634}
1635
1636/// The board's completion **column**, chosen by the service rather than by
1637/// `done_column`.
1638///
1639/// The remote trackers have a two-state notion of "closed", so the export path has to
1640/// pick one local column to send as closed. That is a **remote-translation** concern
1641/// and belongs here; deciding what "complete" *means* is not, and is the service's.
1642///
1643/// Preferring the column the service calls complete means a board whose completion
1644/// category sits on a column other than `done_column` still round-trips: export
1645/// closes the column that is genuinely complete, and import brings it back to that
1646/// same column rather than to `done_column`.
1647fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
1648 // WP09: this was a private re-implementation — the same "first complete column,
1649 // else `done_column`, else `done`" rule the service now owns, duplicated here so
1650 // that `first_work_column` and `remote_to_local_status` could each have a total
1651 // `String`. It is the module's own doc comment, written in WP07, that says a
1652 // second local definition is a C-004 defect, so the duplicate had to go.
1653 crate::workflow::completion_column_or_default(board, config)
1654}
1655
1656/// The first column that is **not** the board's completion column, or the
1657/// completion column when every column is complete.
1658///
1659/// WP09: "not the done column" is a question about **completion**, so it is asked of
1660/// the service rather than of `done_column`. On a board whose `category_map` puts
1661/// `completed` on `shipped` while `done_column` is `done`, the old version returned
1662/// `todo` as the first *work* column when `shipped` is the first column that is
1663/// genuinely finished — so an export labelled a shipped issue as work in progress.
1664fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
1665 // The lifetime is `board`'s, and the `done` fallback cannot borrow from `config`
1666 // — so the completion column is compared as a `String` and the fallback is a
1667 // literal. The alternative, returning `&'a str` from a local, is what forces the
1668 // old `unwrap_or("done")` to exist at all.
1669 let done = crate::workflow::completion_column_or_default(board, config);
1670 board
1671 .columns
1672 .iter()
1673 .find(|c| c.as_str() != done)
1674 .map_or("done", String::as_str)
1675}
1676
1677/// The inverse of `model::civil_from_days` (Howard Hinnant's
1678/// `days_from_civil`): calendar date → days since the Unix epoch.
1679#[must_use]
1680pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1681 let y = if m <= 2 { y - 1 } else { y };
1682 let era = if y >= 0 { y } else { y - 399 } / 400;
1683 let yoe = y - era * 400; // [0, 399]
1684 let mp = (m + 9) % 12; // [0, 11]
1685 let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
1686 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
1687 era * 146_097 + doe - 719_468
1688}
1689
1690/// Parse `YYYY-MM-DD` into days since the Unix epoch.
1691#[must_use]
1692pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
1693 let mut parts = ymd.trim().split('-');
1694 let y: i64 = parts.next()?.parse().ok()?;
1695 let m: u32 = parts.next()?.parse().ok()?;
1696 let d: u32 = parts.next()?.parse().ok()?;
1697 if parts.next().is_some() {
1698 return None;
1699 }
1700 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1701 return None;
1702 }
1703 Some(days_from_civil(y, m, d))
1704}
1705
1706/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
1707/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
1708/// seconds and a `Z` or `±HH:MM` zone.
1709#[must_use]
1710pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
1711 let (date, time) = input.trim().split_once(['T', ' '])?;
1712 let days = date_to_epoch_days(date)?;
1713 let (time, zone) = split_zone(time);
1714 let mut hms = time.split(':');
1715 let hh: i64 = hms.next()?.parse().ok()?;
1716 let mm: i64 = hms.next()?.parse().ok()?;
1717 let ss_part = hms.next()?;
1718 let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
1719 if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
1720 return None;
1721 }
1722 let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
1723 if let Some((zh, zm)) = zone {
1724 secs -= zh * 3600 + zm * 60;
1725 }
1726 Some(secs)
1727}
1728
1729/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
1730/// `(hours, minutes)` zone offset (already sign-adjusted).
1731fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
1732 if time.ends_with('Z') || time.ends_with('z') {
1733 return (&time[..time.len() - 1], Some((0, 0)));
1734 }
1735 match time.rfind(['+', '-']) {
1736 Some(i) if i > 0 => {
1737 let (bare, zone) = time.split_at(i);
1738 let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
1739 let digits = &zone[1..];
1740 let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
1741 (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1742 } else if digits.len() == 2 {
1743 (digits.parse::<i64>().ok(), Some(0))
1744 } else if digits.len() >= 3 {
1745 let (h, m) = digits.split_at(digits.len() - 2);
1746 (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1747 } else {
1748 (None, None)
1749 };
1750 match (zh, zm) {
1751 (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
1752 _ => (bare, None),
1753 }
1754 }
1755 Some(_) | None => (time, None),
1756 }
1757}
1758
1759/// The conflict rule: the remote wins when its `updated_at` is strictly later
1760/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
1761/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
1762/// the local issue is kept.
1763#[must_use]
1764pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
1765 let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
1766 return false;
1767 };
1768 let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
1769 remote > local_end_of_day
1770}
1771
1772/// Redact a resolved secret from text flowing through error/report surfaces.
1773#[must_use]
1774pub fn scrub_secret(text: &str, secret: &str) -> String {
1775 if secret.is_empty() {
1776 return text.to_string();
1777 }
1778 text.replace(secret, "<token>")
1779}
1780
1781/// The contract's missing-binary wording (`contracts/external-tools.md`):
1782/// exit code 3 with `required tool not found: <tool> — install from <source>`.
1783fn not_found_message(program: &str) -> String {
1784 match program {
1785 "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
1786 .to_string(),
1787 _ => format!("required tool not found: {program} — install it and retry"),
1788 }
1789}
1790
1791/// A dry-run stand-in for a remote reference that pass 1 of a real run would
1792/// create. Used only in the preview, so an edge to a not-yet-exported issue reads
1793/// as sendable without inventing an identifier.
1794const DRY_RUN_NEW_REF: &str = "<new remote id>";
1795
1796/// `tix export`: mirror local issues to the remote tracker, recording the
1797/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
1798/// instead of creating duplicates (idempotency, FR-Remote-sync).
1799///
1800/// Returns the number of issues handled (created or updated — `close` on an
1801/// updated GitLab done issue is not counted separately).
1802pub fn export(
1803 store: &Store,
1804 tool: Tool,
1805 config: &Config,
1806 dry_run: bool,
1807 out: &mut dyn Write,
1808) -> Result<SyncReport> {
1809 let mut adapter = adapter_for(tool, config)?;
1810 let mut report = SyncReport::default();
1811 // Built **once** for the whole run, which is the service's rule: resolution is
1812 // not rebuilt per issue. See `workflow::resolution`.
1813 let resolution = crate::workflow::resolution(config, config.default_config(), "default");
1814
1815 if dry_run {
1816 for issue in store.list()? {
1817 match remote_ref(tool, &issue) {
1818 Some(r) => {
1819 writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
1820 }
1821 None => {
1822 writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
1823 }
1824 }
1825 }
1826 writeln!(out, "dry run — {tool} CLI was not invoked")?;
1827 report.handled = store.list()?.len();
1828 // Every issue a real run would export, plus the remote references the
1829 // existing ones already carry. A relation endpoint that is **in the export
1830 // set** is resolvable: pass 1 will give it a remote reference, so the edge
1831 // is sendable and the preview must say so rather than report it as
1832 // unsendable. Only a target genuinely absent from the export set (nothing a
1833 // real run would create) is reported as unsendable.
1834 let export_set: HashSet<u32> = store.list()?.into_iter().map(|i| i.number).collect();
1835 let existing: HashMap<u32, String> = store
1836 .list()?
1837 .into_iter()
1838 .filter_map(|i| remote_ref(tool, &i).map(|r| (i.number, r.to_string())))
1839 .collect();
1840 // The dry run reports the same diagnostics a real run would, so
1841 // "preview then commit" is not the only way to find out that a
1842 // relationship cannot leave the machine.
1843 for issue in store.list()? {
1844 for rel in store.relationships_for(issue.number)? {
1845 // The same adjacency rule as the real pass 2: one edge, from its
1846 // declaring side, so a dry run previews exactly what a real run does
1847 // rather than reporting an edge twice.
1848 if rel.from != issue.number {
1849 continue;
1850 }
1851 let other = if rel.from == issue.number {
1852 rel.to
1853 } else {
1854 rel.from
1855 };
1856 let target = if let Some(r) = existing.get(&other) {
1857 Some(r.as_str())
1858 } else if export_set.contains(&other) {
1859 // Unknown until pass 1 runs; a marker keeps the edge sendable in
1860 // the preview without inventing an identifier.
1861 Some(DRY_RUN_NEW_REF)
1862 } else {
1863 None
1864 };
1865 match map_relation_to_remote(tool, &rel, target) {
1866 MappedRelation::Remote { field, value } => {
1867 writeln!(out, "would export {tool} {field} {value} ({})", rel.id)?;
1868 }
1869 unsupported @ MappedRelation::Unsupported(_) => {
1870 report.note(unsupported, out)?
1871 }
1872 }
1873 }
1874 // And the parent, so a dry run surfaces exactly what a real run would.
1875 if issue.parent.is_some() {
1876 report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
1877 }
1878 // And every project association, for the same reason: on both trackers
1879 // the answer is "it cannot leave the machine", and a user previewing an
1880 // export is exactly who needs to know that before running it.
1881 for project_id in issue.projects.as_deref().unwrap_or_default() {
1882 report.note_project(map_project_to_remote(tool, issue.number, project_id), out)?;
1883 }
1884 // And every epic reference, for the same reason (C-007): the answer is
1885 // the same on both trackers, and a preview that hid it would be the one
1886 // place a reader could not learn it.
1887 for epic_id in issue.epics.as_deref().unwrap_or_default() {
1888 report.note_epic(map_epic_to_remote(tool, issue.number, epic_id), out)?;
1889 }
1890 }
1891 return Ok(report);
1892 }
1893
1894 // **Pass 1** — create or update every issue, and record `issue.number -> remote
1895 // reference`. Projects and epics are sent here, because they hang off the issue
1896 // itself and need no other endpoint to exist.
1897 let mut remote_ids: HashMap<u32, String> = HashMap::new();
1898 let mut handled = 0;
1899 for mut issue in store.list()? {
1900 let remote_status = local_to_remote_status(tool, issue.status.display(), config);
1901 let labels: Vec<String> = issue.labels.clone();
1902 let assignee = issue.assignee.as_deref();
1903 let priority = issue.priority.to_string();
1904 match remote_ref(tool, &issue) {
1905 Some(r) => {
1906 let r = r.to_string();
1907 adapter.update_issue(
1908 &r,
1909 &issue.title,
1910 &issue.body,
1911 &labels,
1912 assignee,
1913 Some(&priority),
1914 &remote_status,
1915 )?;
1916 if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
1917 {
1918 adapter.mark_done(&r)?;
1919 }
1920 writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
1921 }
1922 None => {
1923 let id = adapter.create_issue(
1924 &issue.title,
1925 &issue.body,
1926 &labels,
1927 assignee,
1928 Some(&priority),
1929 &remote_status,
1930 )?;
1931 if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
1932 {
1933 adapter.mark_done(&id)?;
1934 }
1935 match tool {
1936 Tool::GitLab => issue.gitlab_id = Some(id.clone()),
1937 Tool::Linear => issue.linear_id = Some(id.clone()),
1938 }
1939 store.update(&issue.filename, &issue)?;
1940 writeln!(
1941 out,
1942 "created {tool} issue {id} \"{}\" (remote id recorded)",
1943 issue.title
1944 )?;
1945 }
1946 }
1947 // The parent reference is offered to the same shared mapper, on the same
1948 // "after the issue" footing as a relationship: a parent only means
1949 // something once the parent itself exists remotely. Nothing is sent while
1950 // `PARENT_WIRE_VERIFIED` is empty, so this reports rather than acts — and
1951 // the local reference is untouched either way.
1952 report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
1953
1954 // Project associations are offered to the same shared mapper, on the same
1955 // "after the issue" footing as a relationship: what it accepts is sent to the
1956 // adapter, what it does not becomes a diagnostic naming the field. Nothing is
1957 // sendable on either tracker — see `PROJECT_WIRE_VERIFIED` — so this reports
1958 // rather than acts, and the local list is untouched either way (C-005). A
1959 // diagnostic is emitted whether or not the issue has a remote id yet, unlike
1960 // the relationship block below: the membership cannot be sent regardless, so
1961 // suppressing the report while the id is still missing would hide the one
1962 // fact the reader needs.
1963 let mut sendable_projects: Vec<MappedProject> = Vec::new();
1964 for project_id in issue.projects.as_deref().unwrap_or_default() {
1965 match map_project_to_remote(tool, issue.number, project_id) {
1966 MappedProject::Remote { field, value } => {
1967 writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
1968 sendable_projects.push(MappedProject::Remote { field, value });
1969 }
1970 unsupported @ MappedProject::Unsupported(_) => {
1971 report.note_project(unsupported, out)?;
1972 }
1973 }
1974 }
1975
1976 // Epic references are offered to the same shared mapper, on the same
1977 // "after the issue" footing: what it accepts would be sent to the adapter,
1978 // what it does not becomes a diagnostic naming the field. Nothing is
1979 // sendable on either tracker — see `EPIC_WIRE_VERIFIED` — so this reports
1980 // rather than acts, and the local reference list is untouched either way
1981 // (C-007). A diagnostic is emitted whether or not the issue has a remote id
1982 // yet, for the same reason the project block gives: the reference cannot be
1983 // sent regardless, so suppressing the report while the id is missing would
1984 // hide the one fact the reader needs.
1985 let mut sendable_epics: Vec<MappedEpic> = Vec::new();
1986 for epic_id in issue.epics.as_deref().unwrap_or_default() {
1987 match map_epic_to_remote(tool, issue.number, epic_id) {
1988 MappedEpic::Remote { field, value } => {
1989 writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
1990 sendable_epics.push(MappedEpic::Remote { field, value });
1991 }
1992 unsupported @ MappedEpic::Unsupported(_) => {
1993 report.note_epic(unsupported, out)?;
1994 }
1995 }
1996 }
1997
1998 let remote = match remote_ref(tool, &issue) {
1999 Some(r) => r.to_string(),
2000 None => match tool {
2001 Tool::GitLab => issue.gitlab_id.clone(),
2002 Tool::Linear => issue.linear_id.clone(),
2003 }
2004 .unwrap_or_default(),
2005 };
2006 if !remote.is_empty() {
2007 adapter.set_projects(&remote, &sendable_projects)?;
2008 adapter.set_epics(&remote, &sendable_epics)?;
2009 remote_ids.insert(issue.number, remote);
2010 }
2011 handled += 1;
2012 }
2013
2014 // **Pass 2** — the relationships, now that **both** endpoints exist remotely.
2015 // An edge is only meaningful once both issues have a remote reference, and on a
2016 // first export the target is frequently created *after* the issue the edge is
2017 // stored on, so this cannot run inside pass 1. Each is offered to the shared
2018 // mapper with the target's resolved remote reference: what it accepts is sent to
2019 // the adapter, what it does not (including a target that was never exported)
2020 // becomes a diagnostic naming the field. The adapter never decides
2021 // representability.
2022 for issue in store.list()? {
2023 let Some(remote) = remote_ids.get(&issue.number).cloned() else {
2024 continue;
2025 };
2026 let mut sendable = Vec::new();
2027 for rel in store.relationships_for(issue.number)? {
2028 // `relationships_for` is an **adjacency** lookup: it returns every edge
2029 // that *touches* this issue, in its stored form (`from` is the declaring
2030 // side). Send each edge exactly once, from its declaring side — Linear's
2031 // `relations add` is additive, so sending it from both endpoints would
2032 // create a duplicate remote edge (and print the line twice).
2033 if rel.from != issue.number {
2034 continue;
2035 }
2036 // `from` is now this issue, so the other endpoint is `to`; spelled out
2037 // rather than assumed, so the adjacency semantics are not re-learnt by
2038 // the next reader.
2039 let other = if rel.from == issue.number {
2040 rel.to
2041 } else {
2042 rel.from
2043 };
2044 let target = remote_ids.get(&other).map(String::as_str);
2045 match map_relation_to_remote(tool, &rel, target) {
2046 MappedRelation::Remote { field, value } => {
2047 writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
2048 sendable.push(MappedRelation::Remote { field, value });
2049 }
2050 unsupported @ MappedRelation::Unsupported(_) => report.note(unsupported, out)?,
2051 }
2052 }
2053 adapter.set_relations(&remote, &sendable)?;
2054 }
2055 report.handled = handled;
2056 writeln!(
2057 out,
2058 "exported {handled} issue(s) to {tool} ({} diagnostic(s) — local data the remote \
2059 cannot hold)",
2060 report.diagnostics.len()
2061 )?;
2062 Ok(report)
2063}
2064
2065/// `tix import`: pull open remote issues into local files.
2066///
2067/// Conflict resolution: a remote issue that already tracks a local one skips
2068/// the local file when the local `updated` is later or equal; when the remote
2069/// `updated_at` is newer the local file is overwritten (fields + body, keeping
2070/// `created`), with the previous file first copied into the centralized backup
2071/// directory (`.tix-backups/` beside the issues directory, #1581).
2072///
2073/// `policy` is the explicit triage-entry policy (WP07 T034). It decides **only
2074/// where a newly created file lands and what the report says about it**: the
2075/// conflict rules above, remote ids, and adapter behaviour are untouched, and
2076/// the overwrite path never consults it (T036).
2077///
2078/// Returns the number of local files created or overwritten.
2079pub fn import(
2080 store: &Store,
2081 tool: Tool,
2082 config: &Config,
2083 out: &mut dyn Write,
2084 policy: crate::triage::import_policy::ImportPolicy,
2085) -> Result<SyncReport> {
2086 let mut adapter = adapter_for(tool, config)?;
2087 import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
2088}
2089
2090/// [`import`] with the adapter injected — the [`CommandRunner`] pattern, so the
2091/// orchestration under test is the real one and the remote side is a fake that
2092/// returns canned [`RemoteIssue`]s without spawning a process (WP07 T037).
2093///
2094/// `command`s like `tix import` still go through [`import`]; this entry point
2095/// exists so a test can hold every input of the triage policy in one place —
2096/// config, policy, and the remote payloads — instead of installing a fake
2097/// binary on `PATH` and running the compiled CLI to reach it.
2098pub fn import_with_adapter(
2099 store: &Store,
2100 tool: Tool,
2101 config: &Config,
2102 out: &mut dyn Write,
2103 policy: crate::triage::import_policy::ImportPolicy,
2104 adapter: &mut dyn TrackerAdapter,
2105) -> Result<SyncReport> {
2106 // Resolved **before** the loop: a policy this project cannot represent
2107 // refuses the whole import, not the third item of it.
2108 let router = crate::triage::import_policy::resolve(config, policy)?;
2109 let remote_issues = adapter.list_open()?;
2110 let mut report = SyncReport::default();
2111 let mut handled = 0;
2112 // Remote ids imported this run, paired with the local number each one landed
2113 // on. A parent is named by a **remote** id in the payload, so the reference can
2114 // only be restored after the parent itself has been created — which is why
2115 // this is a second pass rather than something set while building each issue.
2116 let mut imported: Vec<(String, u32)> = Vec::new();
2117 // `(child remote id, parent remote id)` for every payload that named one.
2118 let mut claims: Vec<(String, String)> = Vec::new();
2119 // `(remote issue id, the project references that payload reported)` — WP08.
2120 // Collected for **every** payload, and applied after the issues exist, so a
2121 // payload's order cannot decide whether an association lands (T037 step 4).
2122 let mut project_payloads: Vec<(String, Vec<RemoteProject>)> = Vec::new();
2123 // `(remote issue id, the epic references that payload reported)` — WP08. Same
2124 // second-pass treatment as the project payloads, so the order a payload's issues
2125 // arrive in cannot decide whether a reference lands.
2126 let mut epic_payloads: Vec<(String, Vec<RemoteEpic>)> = Vec::new();
2127 // `(remote issue id, the local number it landed on)` for every payload this run
2128 // found a local counterpart for — created here or already tracked. Distinct from
2129 // `imported`, which is create-only and is what the parent pass resolves
2130 // references through.
2131 let mut located: Vec<(String, u32)> = Vec::new();
2132 for remote in remote_issues.clone() {
2133 if let Some(parent) = remote.parent.clone() {
2134 claims.push((remote.id.clone(), parent));
2135 }
2136 if !remote.projects.is_empty() {
2137 project_payloads.push((remote.id.clone(), remote.projects.clone()));
2138 }
2139 if !remote.epics.is_empty() {
2140 epic_payloads.push((remote.id.clone(), remote.epics.clone()));
2141 }
2142 let tracked = store
2143 .list()?
2144 .into_iter()
2145 .find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
2146 match tracked {
2147 None => {
2148 // WP07 T034/T035: the placement decision is made here, once, from
2149 // the remote state and the configured entry column — before any
2150 // byte of the file exists. `requested` comes from this module's
2151 // own mapper rather than being re-derived by the policy, so the
2152 // two answers can be compared in the report instead of guessed at.
2153 let requested = remote_to_local_status(tool, &remote.state, config);
2154 let mapping = router.place(&remote.state, &requested);
2155 let mut issue = Issue {
2156 projects: None,
2157 id: None,
2158 number: 0,
2159 title: remote.title.clone(),
2160 status: Status::new(mapping.landed.clone()),
2161 labels: remote.labels.clone(),
2162 priority: crate::model::Priority::Medium,
2163 assignee: remote.assignee.clone(),
2164 estimate: None,
2165 commit: None,
2166 gitlab_id: None,
2167 linear_id: None,
2168 mission: None,
2169 task: None,
2170 relationships: vec![],
2171 related_issues: vec![],
2172 files: vec![],
2173 cycles: None,
2174 milestones: None,
2175 reviewer: None,
2176 start_date: None,
2177 due_date: None,
2178 created: crate::model::today(),
2179 type_: None,
2180 updated: crate::model::today(),
2181 filename: String::new(),
2182 body: String::new(),
2183 history: crate::model::History::new(),
2184 has_retired_links_key: false,
2185 time_entries: vec![],
2186 parent: None,
2187 series: None,
2188 health: None,
2189 epics: None,
2190 extra_frontmatter: std::collections::BTreeMap::new(),
2191 archived: false,
2192 entered_at: None,
2193 source: None,
2194 };
2195 // T035: entry metadata through the same seam every other entry
2196 // uses. The queue's age is measured from `entered_at`, so an issue
2197 // that entered the board through an import has to record that it
2198 // did — and `import` is the origin data-model §2 documents for it.
2199 //
2200 // The **landing column** decides, not `mapping.routed`: `routed` is a
2201 // policy notion, so under `--triage-policy remote` (or a default whose
2202 // remote-mapped column happens to be the entry column) an imported
2203 // issue landed in the inbox with no stamp and the queue reported
2204 // `unknown` for an issue it was listing. On a project with no
2205 // `[triage]` section the shared rule stamps nothing, which is the
2206 // outcome the old guard was reaching for.
2207 crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
2208 match tool {
2209 Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
2210 Tool::Linear => issue.linear_id = Some(remote.id.clone()),
2211 }
2212 // WP07 T031: a **column check** on the incoming status, and only
2213 // that. `remote_to_local_status` already lands on a column the board
2214 // configures — the service's completion column, or a name match, or
2215 // the first — so this is the belt to that braces, and it means an
2216 // import can never create a file in a column the board lacks. The
2217 // create path performs the same check (WP05), so this is belt *and*
2218 // braces for the remote-sourced case.
2219 //
2220 // Not a transition evaluation: a new issue is not leaving anywhere, so
2221 // the terminal lock has nothing to say. Importing an issue directly
2222 // into a terminal column is legitimate.
2223 let (_, resolution) = crate::workflow::active_resolution(config);
2224 if resolution.resolve(issue.status.display()).is_none() {
2225 // Reported, not written. A skipped item is visible and the rest of
2226 // the batch still lands.
2227 writeln!(
2228 out,
2229 "skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
2230 remote.id, remote.title, issue.status, resolution.board
2231 )?;
2232 report.skipped += 1;
2233 continue;
2234 }
2235 // One transaction per imported issue (#1580), never one over the
2236 // whole import: each remote issue has its own outcome, and a
2237 // failure on one must not roll back the others. `create` claims a
2238 // number and writes the file, so the claim and the bytes are one
2239 // unit.
2240 let path =
2241 store.transaction(|| store.create(&mut issue, &remote.description, None))?;
2242 imported.push((remote.id.clone(), issue.number));
2243 located.push((remote.id.clone(), issue.number));
2244 let name = path
2245 .file_name()
2246 .map(|n| n.to_string_lossy().to_string())
2247 .unwrap_or_default();
2248 // T035: the mapping is named in the same line that names the file,
2249 // and `report_clause` is empty exactly when the default policy
2250 // decided nothing — so a legacy project's report is byte-identical
2251 // to what it was, and a project that routed says where the remote
2252 // state asked for and where the issue landed.
2253 let clause = mapping.report_clause();
2254 writeln!(
2255 out,
2256 "imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
2257 remote.id, remote.title, issue.number, issue.status
2258 )?;
2259 handled += 1;
2260 }
2261 Some(local) => {
2262 // The payload found its local issue. Recorded before the branches
2263 // below, because the association pass needs to attach a remote
2264 // payload's projects to it whether or not this run also overwrote
2265 // its fields — "kept" is a statement about title and body, not about
2266 // membership.
2267 located.push((remote.id.clone(), local.number));
2268 if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
2269 let backup = crate::backup::save(&store.issues_dir, &local.filename);
2270 // Captured before `local` is moved into `overwritten`: the
2271 // skip path restores the local status, and reading it back off
2272 // `overwritten` after the assignment would restore the status the
2273 // *incoming* value was about to overwrite.
2274 let local_status = local.status.clone();
2275 let mut overwritten = local;
2276 let keep_created = overwritten.created.clone();
2277 overwritten.title = remote.title.clone();
2278 overwritten.body = remote.description.clone();
2279 overwritten.labels = remote.labels.clone();
2280 let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
2281 // WP07 T031: validate the incoming status as a **transition**,
2282 // because this *is* one — an existing issue moving columns.
2283 //
2284 // **Skip-and-report**, which is the disposition the design settles
2285 // (T031 step 3). The alternative, apply-and-report, would make a
2286 // board's policy non-binding for the one path nobody watches, and
2287 // a remote whose states the board forbids would quietly win.
2288 // Skipping keeps the local file truthful and puts the conflict in
2289 // the report where a human will see it. Recorded here rather than
2290 // in `data-model.md` §8, which is a protected path for this lane.
2291 let (_, resolution) = crate::workflow::active_resolution(config);
2292 let transition = crate::workflow::validate_transition(
2293 &overwritten,
2294 incoming.display(),
2295 &resolution,
2296 config,
2297 None,
2298 );
2299 if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
2300 // The status is left alone; the other fields still import,
2301 // because they are not what the policy governs. The file is
2302 // rewritten, so this is a **partial** import — reported as such
2303 // rather than as a plain overwrite.
2304 overwritten.status = local_status.clone();
2305 overwritten.title = remote.title.clone();
2306 overwritten.body = remote.description.clone();
2307 overwritten.labels = remote.labels.clone();
2308 if let Some(a) = &remote.assignee {
2309 overwritten.assignee = Some(a.clone());
2310 }
2311 overwritten.created = keep_created;
2312 overwritten.bump_updated();
2313 let filename = overwritten.filename.clone();
2314 // One transaction for the item's corpus write (#1580): the
2315 // `update` writes the file and may then reconcile its
2316 // location, so those two mutations are one unit. The
2317 // backup copy above is deliberately out-of-corpus and is
2318 // not part of the journaled set.
2319 store.transaction(|| store.update(&filename, &overwritten))?;
2320 writeln!(
2321 out,
2322 "overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
2323 (kept {})",
2324 remote.id, overwritten.title, overwritten.status
2325 )?;
2326 report.skipped += 1;
2327 handled += 1;
2328 continue;
2329 }
2330 overwritten.status = incoming;
2331 if let Some(a) = &remote.assignee {
2332 overwritten.assignee = Some(a.clone());
2333 }
2334 overwritten.created = keep_created;
2335 overwritten.bump_updated();
2336 let filename = overwritten.filename.clone();
2337
2338 // An import that moves a locally-blocked issue into the
2339 // board's completion column goes through the same blocked-move
2340 // policy as every other surface -- WP04 T016 step 6 names
2341 // "any bulk or import path" explicitly, and an import that
2342 // can end an issue in the completion column with open
2343 // blockers is the sharpest form of that.
2344 //
2345 // It **records** the acknowledgement rather than refusing, and
2346 // the asymmetry is deliberate. There is no interactive
2347 // confirmation available mid-import, and refusing would make
2348 // `tix import` unusable whenever one issue in a batch carries
2349 // a stale blocker -- turning a stale local edge into a
2350 // permanently un-syncable board. The user ran the import;
2351 // that is the decision, and the event exists to preserve it.
2352 // Where a human is present to answer, refusing is right
2353 // (`tix move`, `tix edit --status`, `move_issue`); here the
2354 // honest record is that the import did it.
2355 let guard = crate::commands::mv::blocked_move_guard(
2356 config,
2357 store,
2358 &overwritten,
2359 overwritten.status.display(),
2360 )?;
2361 if let Some(warning) = guard.warning() {
2362 overwritten.history.append_now(
2363 &crate::storage::actor_identity(),
2364 crate::model::HistoryEventKind::BlockedMoveAcknowledged,
2365 &format!("imported to {}: {warning}", overwritten.status.display()),
2366 );
2367 }
2368 store.transaction(|| store.update(&filename, &overwritten))?;
2369 let backup_label = backup
2370 .as_ref()
2371 .map(|p| p.display().to_string())
2372 .unwrap_or_else(|| "none".to_string());
2373 writeln!(
2374 out,
2375 "overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup_label})",
2376 remote.id, overwritten.title
2377 )?;
2378 handled += 1;
2379 } else {
2380 writeln!(
2381 out,
2382 "kept {tool} issue {} \"{}\" (local updated later or equal)",
2383 remote.id, remote.title
2384 )?;
2385 }
2386 }
2387 }
2388 }
2389 restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
2390 apply_imported_projects(
2391 store,
2392 config,
2393 tool,
2394 &project_payloads,
2395 &located,
2396 &mut report,
2397 out,
2398 )?;
2399 apply_imported_epics(
2400 store,
2401 config,
2402 tool,
2403 &epic_payloads,
2404 &located,
2405 &mut report,
2406 out,
2407 )?;
2408 writeln!(out, "imported {handled} {tool} issue(s)")?;
2409 report.handled = handled;
2410 Ok(report)
2411}
2412
2413/// Restore the parent references an import's payload reported.
2414///
2415/// A **second pass**, because a payload names a parent by *remote* id and the
2416/// parent may well be imported after its child. Setting the field while building
2417/// each issue would therefore succeed or fail depending on the order the remote
2418/// happened to return, which is the order-dependence NFR-003 forbids.
2419///
2420/// Every write goes through [`crate::hierarchy::set_parent`] rather than a field
2421/// assignment, so an imported reference obeys the same rules as a CLI write: it is
2422/// validated before anything is written, and a refusal leaves the file
2423/// byte-identical. T032's note is explicit — "an import that would create a cycle
2424/// is a defect, not a special case" — and the service is what decides that, not
2425/// this loop.
2426///
2427/// `claims` is `(child remote id, parent remote id)` and `imported` is
2428/// `(remote id, local number)`. A pair where either side does not resolve becomes a
2429/// diagnostic rather than a write: a reference to an issue this run did not import
2430/// is exactly the unresolved-parent state WP05 added a finding for, and
2431/// manufacturing it here would be this loop's own defect.
2432fn restore_imported_parents(
2433 store: &Store,
2434 tool: Tool,
2435 claims: &[(String, String)],
2436 imported: &[(String, u32)],
2437 report: &mut SyncReport,
2438 out: &mut dyn Write,
2439) -> Result<()> {
2440 for (child_remote, parent_remote) in claims {
2441 let child = imported
2442 .iter()
2443 .find(|(id, _)| id == child_remote)
2444 .map(|(_, number)| *number);
2445 let parent = imported
2446 .iter()
2447 .find(|(id, _)| id == parent_remote)
2448 .map(|(_, number)| *number);
2449 let (Some(child), Some(parent)) = (child, parent) else {
2450 report.note_parent(
2451 MappedParent::Unsupported(RemoteMappingDiagnostic {
2452 relation_id: format!("{child_remote}->{parent_remote}"),
2453 field: parent_field(tool).to_string(),
2454 tool,
2455 message: format!(
2456 "remote issue {child_remote} reports parent {parent_remote}, \
2457 which this import did not create — it may be closed, \
2458 filtered out, or in another project; no local reference is \
2459 written, so the issue stays parentless rather than \
2460 unresolved"
2461 ),
2462 }),
2463 out,
2464 )?;
2465 continue;
2466 };
2467 // Through `Store::set_parent`, which resolves, validates with the
2468 // hierarchy service, writes, and records the one History entry — the
2469 // same path `tix edit --parent` takes, so an imported reference is
2470 // indistinguishable from a typed one. A refusal is reported and nothing is
2471 // written.
2472 let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
2473 match store.set_parent(&child_sel, &parent_sel) {
2474 Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
2475 Err(e) => {
2476 writeln!(
2477 out,
2478 "could not restore the parent of {tool} issue #{child}: {e}; the \
2479 reference was not written"
2480 )?;
2481 report.diagnostics.push(RemoteMappingDiagnostic {
2482 relation_id: format!("{child}->{parent}"),
2483 field: parent_field(tool).to_string(),
2484 tool,
2485 message: format!(
2486 "the remote reports #{child} under #{parent}, but the hierarchy \
2487 service refused it: {e}"
2488 ),
2489 });
2490 }
2491 }
2492 }
2493 Ok(())
2494}
2495
2496/// Apply the project associations an import's payloads reported (WP08 T037).
2497///
2498/// A **second pass**, like [`restore_imported_parents`], because the association
2499/// belongs to an issue that must exist locally first and because the order a
2500/// remote happened to list its issues in must not decide the outcome (T037 step
2501/// 4). Every payload that found a local issue is in `located`, so an issue created
2502/// *after* its sibling still gets its memberships.
2503///
2504/// ## Every write goes through the project service
2505///
2506/// [`Store::associate`] is the same path `tix project add` will take, so
2507/// uniqueness, reference validation and the one History entry per accepted change
2508/// are the service's decisions rather than a second membership rule here. An
2509/// already-present association is the service's idempotent no-op: it writes
2510/// nothing, appends nothing, and leaves the canonical file byte-stable — which is
2511/// what makes a repeated import a true no-op (T037 step 3).
2512///
2513/// ## Matching is by id, and only by id
2514///
2515/// `resolve_project_id` also accepts a project *name*, because a human selects
2516/// projects by name. An import must not: a name is a display label (C-002), so a
2517/// remote that renames a project — or a local registry whose name happens to
2518/// equal the remote's id — would resolve to the wrong project, or to a second
2519/// association for a membership that is already there. The registry is therefore
2520/// consulted by **key** before the service is called, which both enforces
2521/// id-only matching and makes the refusal diagnosable instead of an
2522/// `unknown_project` error string.
2523///
2524/// ## An unknown id is diagnosed, never written
2525///
2526/// Writing the association would manufacture a dangling reference — one per
2527/// issue, per run, for a project the registry has never heard of: the "missing
2528/// reference storm" T037 step 4 names. The remote keeps the association, the
2529/// local file is untouched, and the diagnostic names the field, the tool and the
2530/// id (C-005). The registry itself is **never** written: the local
2531/// `[projects.<id>]` section is this repository's configuration (C-001) and not
2532/// the remote's to rewrite, which is also why there is no window in which a
2533/// partially written registry could exist.
2534///
2535/// A payload the import skipped — an issue whose remote state is not a column on
2536/// the board — was already reported on its own line and has no local issue to
2537/// attach a membership to, so it is passed over here rather than diagnosed twice.
2538fn apply_imported_projects(
2539 store: &Store,
2540 config: &Config,
2541 tool: Tool,
2542 payloads: &[(String, Vec<RemoteProject>)],
2543 located: &[(String, u32)],
2544 report: &mut SyncReport,
2545 out: &mut dyn Write,
2546) -> Result<()> {
2547 if payloads.is_empty() {
2548 return Ok(());
2549 }
2550 // Resolved once, with a **no-op** warning sink: this is the same resolution the
2551 // adapters use, and quoting the project name the remote reported is the one
2552 // place remote output reaches a diagnostic, so it has to be scrubbed. The sink
2553 // is silent because `token::resolve` already warned on the adapter's behalf for
2554 // this run — warning twice per import would be noise, not extra safety.
2555 let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
2556 .secret()
2557 .map(str::to_string);
2558 for (remote_id, projects) in payloads {
2559 let Some(local_number) = located
2560 .iter()
2561 .find(|(id, _)| id == remote_id)
2562 .map(|(_, number)| *number)
2563 else {
2564 continue;
2565 };
2566 for project in projects {
2567 let project_id = match map_project_from_remote(tool, project) {
2568 MappedProject::Remote { value, .. } => value,
2569 MappedProject::Unsupported(mut diagnostic) => {
2570 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2571 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2572 continue;
2573 }
2574 };
2575 if !config.projects.contains_key(project_id.as_str()) {
2576 let mut diagnostic = RemoteMappingDiagnostic {
2577 relation_id: format!("{local_number}->{project_id}"),
2578 field: project_field(tool).to_string(),
2579 tool,
2580 message: format!(
2581 "the remote reports #{local_number} in project `{project_id}`{}, \
2582 which this repository's registry does not declare; nothing was \
2583 written — the association stays on the remote and no missing \
2584 reference was created locally",
2585 project
2586 .name
2587 .as_deref()
2588 .map(|name| format!(" (`{name}`)"))
2589 .unwrap_or_default()
2590 ),
2591 };
2592 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2593 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2594 continue;
2595 }
2596 match store.associate(&local_number.to_string(), &project_id) {
2597 Ok(applied) if applied.changed => {
2598 writeln!(
2599 out,
2600 "associated {tool} issue #{local_number} with {}",
2601 crate::projects::association_detail(&project_id, true)
2602 )?;
2603 }
2604 // Already associated: the service's no-op, so nothing was written and
2605 // no History entry was appended. Silent on purpose — a report line per
2606 // unchanged association would make every re-import look like work.
2607 Ok(_) => {}
2608 Err(error) => {
2609 let mut diagnostic = RemoteMappingDiagnostic {
2610 relation_id: format!("{local_number}->{project_id}"),
2611 field: project_field(tool).to_string(),
2612 tool,
2613 message: format!(
2614 "the remote reports #{local_number} in project `{project_id}`, \
2615 but the project service refused the association: {error}; the \
2616 local association list is unchanged"
2617 ),
2618 };
2619 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2620 report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2621 }
2622 }
2623 }
2624 }
2625 Ok(())
2626}
2627
2628/// Apply the epic references an import's payloads reported (WP08, mission
2629/// `epics-work-items-01M3BYW4`).
2630///
2631/// A **second pass**, like [`apply_imported_projects`], because the reference
2632/// belongs to an issue that must exist locally first and because the order a remote
2633/// happened to list its issues in must not decide the outcome. Every payload that
2634/// found a local issue is in `located`, so an issue created *after* its sibling
2635/// still gets its references.
2636///
2637/// ## Every write goes through the epic service
2638///
2639/// [`Store::reference_epic`] is the same path `tix edit --add-epic` takes, so
2640/// uniqueness, reference validation and the one History entry per accepted change
2641/// are the service's decisions rather than a second reference rule here. An
2642/// already-present reference is the service's idempotent no-op: it writes nothing,
2643/// appends nothing, and leaves the canonical file byte-stable — which is what makes
2644/// a repeated import a true no-op (T039 step 3).
2645///
2646/// ## Matching is by id, and only by id
2647///
2648/// `resolve_epic_id` also accepts an epic *name*, because a human selects epics by
2649/// name. An import must not: a name is a display label (C-003), so a remote that
2650/// renames an epic — or a local registry whose name happens to equal the remote's id
2651/// — would resolve to the wrong epic, or to a second reference for a reference that
2652/// is already there. The registry is therefore consulted by **key** before the
2653/// service is called, which both enforces id-only matching and makes the refusal
2654/// diagnosable instead of an `unknown_epic` error string.
2655///
2656/// ## An unknown id is diagnosed, never written
2657///
2658/// Writing the reference would manufacture a dangling reference — one per issue, per
2659/// run, for an epic the registry has never heard of: the "missing reference storm"
2660/// T039 step 4 names. The remote keeps the reference, the local file is untouched,
2661/// and the diagnostic names the field, the tool and the id (C-007). The registry
2662/// itself is **never** written: the local `[epics.<id>]` section is this repository's
2663/// configuration (C-004) and not the remote's to rewrite, which is also why there is
2664/// no window in which a partially written registry could exist.
2665///
2666/// A payload the import skipped — an issue whose remote state is not a column on the
2667/// board — was already reported on its own line and has no local issue to attach a
2668/// reference to, so it is passed over here rather than diagnosed twice.
2669fn apply_imported_epics(
2670 store: &Store,
2671 config: &Config,
2672 tool: Tool,
2673 payloads: &[(String, Vec<RemoteEpic>)],
2674 located: &[(String, u32)],
2675 report: &mut SyncReport,
2676 out: &mut dyn Write,
2677) -> Result<()> {
2678 if payloads.is_empty() {
2679 return Ok(());
2680 }
2681 // Resolved once, with a **no-op** warning sink: the same resolution the adapters
2682 // use, and quoting the epic name the remote reported is the one place remote
2683 // output reaches a diagnostic, so it has to be scrubbed. The sink is silent
2684 // because `token::resolve` already warned on the adapter's behalf for this run.
2685 let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
2686 .secret()
2687 .map(str::to_string);
2688 for (remote_id, epics) in payloads {
2689 let Some(local_number) = located
2690 .iter()
2691 .find(|(id, _)| id == remote_id)
2692 .map(|(_, number)| *number)
2693 else {
2694 continue;
2695 };
2696 for epic in epics {
2697 let epic_id = match map_epic_from_remote(tool, epic) {
2698 MappedEpic::Remote { value, .. } => value,
2699 MappedEpic::Unsupported(mut diagnostic) => {
2700 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2701 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2702 continue;
2703 }
2704 };
2705 if !config.epics.contains_key(epic_id.as_str()) {
2706 let mut diagnostic = RemoteMappingDiagnostic {
2707 relation_id: format!("{local_number}->{epic_id}"),
2708 field: EPIC_FIELD.to_string(),
2709 tool,
2710 message: format!(
2711 "the remote reports #{local_number} in epic `{epic_id}`{}, which \
2712 this repository's registry does not declare; nothing was written \
2713 — the reference stays on the remote and no missing reference \
2714 was created locally",
2715 epic.name
2716 .as_deref()
2717 .map(|name| format!(" (`{name}`)"))
2718 .unwrap_or_default()
2719 ),
2720 };
2721 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2722 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2723 continue;
2724 }
2725 match store.reference_epic(&local_number.to_string(), &epic_id) {
2726 Ok(applied) if applied.changed => {
2727 writeln!(
2728 out,
2729 "referenced {tool} issue #{local_number} with {}",
2730 crate::epics::reference_detail(&epic_id, true)
2731 )?;
2732 }
2733 // Already referenced: the service's no-op, so nothing was written and
2734 // no History entry was appended. Silent on purpose — a report line per
2735 // unchanged reference would make every re-import look like work.
2736 Ok(_) => {}
2737 Err(error) => {
2738 let mut diagnostic = RemoteMappingDiagnostic {
2739 relation_id: format!("{local_number}->{epic_id}"),
2740 field: EPIC_FIELD.to_string(),
2741 tool,
2742 message: format!(
2743 "the remote reports #{local_number} in epic `{epic_id}`, but \
2744 the epic service refused the reference: {error}; the local \
2745 reference list is unchanged"
2746 ),
2747 };
2748 scrub_diagnostic(&mut diagnostic, secret.as_deref());
2749 report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2750 }
2751 }
2752 }
2753 }
2754 Ok(())
2755}
2756
2757/// Whether a computed **remote** status conveys "done" in that tracker's own
2758/// vocabulary.
2759///
2760/// Deliberately **not** the completion decision. `closed` is GitLab's word and
2761/// `Done` is Linear's; neither is a local column name, so this cannot be a
2762/// category question, and routing it through the service would mean asking the
2763/// service about a string from a different vocabulary.
2764///
2765/// It used to decide *local* completion at the export call sites, by mapping the
2766/// local status out to a remote string and testing that — a round trip that can
2767/// disagree with the category rule on any board with a category_map. Those call
2768/// sites now ask \`workflow::is_completed\` about the local issue (C-004), and this
2769/// function keeps only the narrower job it can actually do: telling an adapter
2770/// whether its own status update already closed the remote issue.
2771fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
2772 match tool {
2773 Tool::GitLab => issue.gitlab_id.as_deref(),
2774 Tool::Linear => issue.linear_id.as_deref(),
2775 }
2776}
2777
2778/// Build and configure the adapter for `tool`: resolves the token with env
2779/// precedence, validates the `[export]` target section, and returns the
2780/// tool-specific adapter wired to a [`ProcessRunner`].
2781fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
2782 // The contract's warning starts with "warning:" — no `tix:` prefix.
2783 let mut warn = |w: &str| eprintln!("{w}");
2784 let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
2785 let secret = resolution.secret().map(str::to_string);
2786 match tool {
2787 Tool::GitLab => {
2788 let rc = config.export.gitlab.as_ref().ok_or_else(|| {
2789 TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
2790 })?;
2791 let project = rc.project.clone().ok_or_else(|| {
2792 TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
2793 })?;
2794 Ok(Box::new(glab::GitLabAdapter::new(
2795 ProcessRunner,
2796 secret,
2797 rc.base_url.clone(),
2798 project,
2799 )))
2800 }
2801 Tool::Linear => {
2802 let rc = config.export.linear.as_ref().ok_or_else(|| {
2803 TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
2804 })?;
2805 let team = rc.team.clone().ok_or_else(|| {
2806 TixError::Config(
2807 "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
2808 .to_string(),
2809 )
2810 })?;
2811 Ok(Box::new(linear::LinearAdapter::new(
2812 ProcessRunner,
2813 secret,
2814 team,
2815 )))
2816 }
2817 }
2818}
2819
2820#[cfg(test)]
2821mod tests {
2822 use super::*;
2823
2824 #[test]
2825 fn target_parsing_accepts_only_the_two_contract_targets() {
2826 assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
2827 assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
2828 assert!(Tool::from_target("github").is_err());
2829 assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
2830 assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
2831 assert_eq!(Tool::GitLab.binary(), "glab");
2832 assert_eq!(Tool::Linear.binary(), "linear-cli");
2833 }
2834
2835 #[test]
2836 fn gitlab_status_mapping() {
2837 let config = Config::default();
2838 assert_eq!(
2839 local_to_remote_status(Tool::GitLab, "todo", &config),
2840 "opened"
2841 );
2842 assert_eq!(
2843 local_to_remote_status(Tool::GitLab, "done", &config),
2844 "closed"
2845 );
2846 assert_eq!(
2847 remote_to_local_status(Tool::GitLab, "opened", &config),
2848 "todo"
2849 );
2850 assert_eq!(
2851 remote_to_local_status(Tool::GitLab, "closed", &config),
2852 "done"
2853 );
2854 assert_eq!(
2855 remote_to_local_status(Tool::GitLab, "anything-else", &config),
2856 "todo"
2857 );
2858 }
2859
2860 #[test]
2861 fn linear_status_mapping() {
2862 let config = Config::default();
2863 assert_eq!(
2864 local_to_remote_status(Tool::Linear, "todo", &config),
2865 "todo"
2866 );
2867 assert_eq!(
2868 local_to_remote_status(Tool::Linear, "in review", &config),
2869 "todo"
2870 );
2871 assert_eq!(
2872 local_to_remote_status(Tool::Linear, "done", &config),
2873 "Done"
2874 );
2875 assert_eq!(
2876 remote_to_local_status(Tool::Linear, "In Progress", &config),
2877 "in progress"
2878 );
2879 assert_eq!(
2880 remote_to_local_status(Tool::Linear, "Done", &config),
2881 "done"
2882 );
2883 assert_eq!(
2884 remote_to_local_status(Tool::Linear, "Unknown", &config),
2885 "todo"
2886 );
2887 }
2888
2889 #[test]
2890 fn days_from_civil_roundtrips_civil_from_days() {
2891 // spot-check independent epoch anchors (known values)
2892 assert_eq!(days_from_civil(1970, 1, 1), 0);
2893 assert_eq!(days_from_civil(2000, 1, 1), 10957);
2894 assert_eq!(days_from_civil(1970, 1, 2), 1);
2895 assert_eq!(days_from_civil(2026, 5, 11), 20584);
2896 }
2897
2898 #[test]
2899 fn date_to_epoch_days_parses_only_yyyymmdd() {
2900 assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
2901 assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
2902 assert_eq!(date_to_epoch_days("not-a-date"), None);
2903 assert_eq!(date_to_epoch_days("2026-13-01"), None);
2904 assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
2905 }
2906
2907 #[test]
2908 fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
2909 let day = days_from_civil(2026, 5, 11) * 86_400;
2910 assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
2911 assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
2912 assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
2913 assert_eq!(
2914 iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
2915 Some(day - 7200)
2916 );
2917 assert_eq!(
2918 iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
2919 Some(day + 19_800)
2920 );
2921 assert_eq!(
2922 iso_to_epoch_secs("2026-05-11T10:30:00Z"),
2923 Some(day + 37_800)
2924 );
2925 assert_eq!(iso_to_epoch_secs("garbage"), None);
2926 }
2927
2928 #[test]
2929 fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
2930 // local `updated` = 2026-05-11 is interpreted as end of day.
2931 assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
2932 assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
2933 assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
2934 assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
2935 // unparseable remote timestamp keeps the local issue
2936 assert!(!is_remote_newer("not-a-time", "2026-05-11"));
2937 }
2938
2939 #[test]
2940 fn scrub_replaces_secret_everywhere_and_preserves_text() {
2941 assert_eq!(
2942 scrub_secret("token=sekrit end", "sekrit"),
2943 "token=<token> end"
2944 );
2945 assert_eq!(
2946 scrub_secret("token=sekrit token=sekrit", "sekrit"),
2947 "token=<token> token=<token>"
2948 );
2949 assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
2950 assert_eq!(scrub_secret("empty secret", ""), "empty secret");
2951 }
2952
2953 // ---- WP07 T029: the relationship mapping seam ----
2954
2955 fn rel(kind: crate::relationships::RelationKind) -> crate::relationships::Relationship {
2956 let mut r = crate::relationships::Relationship::new(1, 2, kind, "Ship it");
2957 r.id = "rel_TEST".to_string();
2958 r
2959 }
2960
2961 /// Every relationship either maps or diagnoses, and the diagnostic
2962 /// distinguishes the reasons (T029 step 5, re-pointed once Linear's
2963 /// `relations add` was verified).
2964 ///
2965 /// - `blocks`/`blocked_by` for **Linear** — expressible in the model and
2966 /// drivable from the CLI, so they **map**, carrying the target's remote
2967 /// identifier.
2968 /// - `blocks`/`blocked_by` for **GitLab** — expressible in the model but the
2969 /// CLI is **verified to have no relation command at all** (tix #1354), so it
2970 /// diagnoses and says *that*, never "not verified".
2971 /// - `related`/`duplicates` — no remote field at all.
2972 #[test]
2973 fn every_relationship_maps_only_where_the_wire_is_verified_and_otherwise_diagnoses() {
2974 for tool in [Tool::GitLab, Tool::Linear] {
2975 for kind in [
2976 crate::relationships::RelationKind::Blocks,
2977 crate::relationships::RelationKind::BlockedBy,
2978 crate::relationships::RelationKind::Related,
2979 crate::relationships::RelationKind::Duplicates,
2980 ] {
2981 let model_supports = matches!(
2982 kind,
2983 crate::relationships::RelationKind::Blocks
2984 | crate::relationships::RelationKind::BlockedBy
2985 );
2986 match map_relation_to_remote(tool, &rel(kind), Some("ENG-42")) {
2987 MappedRelation::Remote { field, value } => {
2988 assert!(
2989 model_supports && RELATION_WIRE_VERIFIED.contains(&tool),
2990 "{tool}/{kind} mapped, so the tool must model the kind and \
2991 be on the verified wire"
2992 );
2993 assert_ne!(field, "");
2994 if tool == Tool::Linear {
2995 assert_eq!(
2996 value, "ENG-42",
2997 "Linear sends the target's remote identifier"
2998 );
2999 } else {
3000 assert!(value.contains('#'));
3001 }
3002 }
3003 MappedRelation::Unsupported(d) => {
3004 assert!(
3005 !RELATION_WIRE_VERIFIED.contains(&tool) || !model_supports,
3006 "{tool}/{kind} is on the verified wire and modelled, so it \
3007 must map, not diagnose"
3008 );
3009 assert_eq!(d.tool, tool, "the diagnostic names the tool");
3010 assert_eq!(d.relation_id, "rel_TEST");
3011 assert!(
3012 !d.field.is_empty(),
3013 "the diagnostic must name the specific field: {d}"
3014 );
3015 assert!(d.to_string().contains(&d.field), "and print it: {d}");
3016 if model_supports {
3017 assert!(
3018 RELATION_CLI_ABSENT.contains(&tool)
3019 && d.message.contains("exposes no issue-relation command")
3020 && !d.message.contains("not verified"),
3021 "{tool}/{kind}: a model-supported kind on an unverified \
3022 tool must name the real reason; a CLI verified to have \
3023 no relation command must say so, not 'not verified': {d}"
3024 );
3025 } else {
3026 assert!(
3027 !d.message.contains("not verified"),
3028 "{tool}/{kind}: a kind with no remote field must not \
3029 blame an unverified verb: {d}"
3030 );
3031 }
3032 }
3033 }
3034 }
3035 }
3036 }
3037
3038 /// A blocking edge whose target has no remote reference is **reported, never
3039 /// guessed** (C-005): Linear's `relations add` needs the target's remote
3040 /// identifier, and inventing one would be worse than a diagnostic.
3041 #[test]
3042 fn a_relation_without_a_remote_target_is_reported_not_guessed() {
3043 let MappedRelation::Unsupported(d) = map_relation_to_remote(
3044 Tool::Linear,
3045 &rel(crate::relationships::RelationKind::Blocks),
3046 None,
3047 ) else {
3048 panic!("a missing target must not map");
3049 };
3050 assert_eq!(d.tool, Tool::Linear);
3051 assert_eq!(d.field, "blocks");
3052 assert!(
3053 d.message.contains("#2") && d.message.contains("not been exported"),
3054 "the diagnostic names the target and why: {d}"
3055 );
3056 }
3057
3058 /// The forward and reverse directions agree about the verified list.
3059 ///
3060 /// The reverse direction must **not** invent an edge from a payload the forward
3061 /// direction would never have written — otherwise an import could create a
3062 /// relationship the export never made, which is the mirror image of the silent
3063 /// drop the contract forbids. So it maps exactly when the wire is verified.
3064 #[test]
3065 fn the_reverse_direction_maps_exactly_when_the_wire_is_verified() {
3066 for tool in [Tool::GitLab, Tool::Linear] {
3067 let field = match tool {
3068 Tool::GitLab => "blocking",
3069 Tool::Linear => "blocks",
3070 };
3071 match map_relation_from_remote(tool, field, "blocks #2", "rel_TEST") {
3072 MappedRelation::Remote { .. } => assert!(
3073 RELATION_WIRE_VERIFIED.contains(&tool),
3074 "{tool}: the reverse direction mapped, so the wire must be verified"
3075 ),
3076 MappedRelation::Unsupported(d) => {
3077 assert!(
3078 !RELATION_WIRE_VERIFIED.contains(&tool),
3079 "{tool}: the wire is verified, so the reverse direction must map"
3080 );
3081 assert_eq!(d.tool, tool);
3082 assert_ne!(d.relation_id, "");
3083 }
3084 }
3085 }
3086 }
3087
3088 /// A remote payload the export could not have written is **ignored with a
3089 /// diagnostic**, never turned into a local edge.
3090 ///
3091 /// The alternative — coercing an unrecognised field to a default kind — would
3092 /// let an import invent a relationship the export never made, which is the
3093 /// mirror image of silently dropping a local one.
3094 #[test]
3095 fn an_unrecognised_remote_relation_field_never_becomes_a_local_edge() {
3096 for tool in [Tool::GitLab, Tool::Linear] {
3097 for (field, value) in [
3098 ("parent", "#2"),
3099 ("blocks", "not-a-number"),
3100 ("whatever", ""),
3101 ] {
3102 match map_relation_from_remote(tool, field, value, "rel_X") {
3103 MappedRelation::Remote { .. } => {
3104 panic!("{tool}: `{field}={value}` must not become a local edge")
3105 }
3106 MappedRelation::Unsupported(d) => {
3107 assert_eq!(d.tool, tool);
3108 assert_eq!(d.field, field, "the diagnostic names the field");
3109 }
3110 }
3111 }
3112 }
3113 }
3114
3115 /// The two adapters cannot drift: the seam is one function, keyed on `Tool`
3116 /// (T033 step 5, and T029's note).
3117 ///
3118 /// Asserted by construction — a single `map_relation_to_remote` serves both —
3119 /// and pinned here so adding a tracker-specific branch to one of them is a
3120 /// red test rather than a silent divergence.
3121 #[test]
3122 fn both_trackers_share_one_mapping_seam() {
3123 let source = include_str!("mod.rs");
3124 let fns = [
3125 "pub fn map_relation_to_remote",
3126 "pub fn map_relation_from_remote",
3127 ];
3128 for f in fns {
3129 assert_eq!(
3130 source.matches(&format!("{f}(")).count(),
3131 1,
3132 "{f} must be *defined* exactly once, in this module, for both \
3133 trackers — a doc comment naming it is not a second implementation"
3134 );
3135 }
3136 // And neither adapter file may define its own copy. `include_str!` needs a
3137 // literal, so the pair is spelled out rather than looped over.
3138 for (name, text) in [
3139 ("glab.rs", include_str!("glab.rs")),
3140 ("linear.rs", include_str!("linear.rs")),
3141 ] {
3142 assert!(
3143 !text.contains("fn map_relation_to_remote")
3144 && !text.contains("fn map_relation_from_remote"),
3145 "{name} must not define its own mapping seam; the shared one is \\
3146 keyed on `Tool` precisely so the two cannot diverge"
3147 );
3148 }
3149 }
3150
3151 /// A `SyncReport` records the count and the diagnostics together, so a caller
3152 /// cannot report success while quietly dropping a relationship.
3153 #[test]
3154 fn a_sync_report_carries_both_the_count_and_the_diagnostics() {
3155 let mut report = SyncReport::default();
3156 let mut sink: Vec<u8> = Vec::new();
3157 report
3158 .note(
3159 map_relation_to_remote(
3160 Tool::GitLab,
3161 &rel(crate::relationships::RelationKind::Related),
3162 None,
3163 ),
3164 &mut sink,
3165 )
3166 .unwrap();
3167 report
3168 .note(
3169 map_relation_to_remote(
3170 Tool::GitLab,
3171 &rel(crate::relationships::RelationKind::Blocks),
3172 None,
3173 ),
3174 &mut sink,
3175 )
3176 .unwrap();
3177 // Both are diagnostics: GitLab is not wire-verified, so the mapped
3178 // branch is unreachable and the count cannot be 1.
3179 let expected = if RELATION_WIRE_VERIFIED.contains(&Tool::GitLab) {
3180 1
3181 } else {
3182 2
3183 };
3184 assert_eq!(
3185 report.diagnostics.len(),
3186 expected,
3187 "the report records every relation the tool cannot carry"
3188 );
3189 assert_eq!(report.handled, 0, "no issues were handled here");
3190 // It is on the human stream too, so the count and the output agree.
3191 let text = String::from_utf8(sink).unwrap();
3192 assert!(
3193 text.contains("cannot represent"),
3194 "the stream says so: {text}"
3195 );
3196 }
3197 // ---- WP07 T032/T033: completion comes from the service, nowhere else ----
3198
3199 /// A board whose `category_map` puts `completed` on `shipped` while
3200 /// `done_column` is `done` — so the two disagree, and a name comparison picks the
3201 /// wrong one. Every assertion below is vacuous on the default board, which is why
3202 /// this fixture exists rather than `Config::default()`.
3203 fn split_completion_config() -> Config {
3204 Config {
3205 kanban: crate::config::KanbanSection {
3206 configurations: indexmap::IndexMap::from([(
3207 "default".to_string(),
3208 crate::config::BoardConfiguration {
3209 columns: vec!["todo".into(), "shipped".into(), "done".into()],
3210 done_column: Some("done".to_string()),
3211 category_map: indexmap::IndexMap::from([
3212 ("todo".to_string(), "unstarted".to_string()),
3213 ("shipped".to_string(), "completed".to_string()),
3214 ("done".to_string(), "active".to_string()),
3215 ]),
3216 ..Default::default()
3217 },
3218 )]),
3219 },
3220 ..Config::default()
3221 }
3222 }
3223
3224 #[test]
3225 fn t032_a_remote_closed_state_maps_to_the_services_completion_column() {
3226 // Not to `done_column`. The remote's `closed` must round-trip to the column
3227 // the *service* calls complete, or export and import disagree with each other
3228 // and with the board.
3229 let config = split_completion_config();
3230 assert_eq!(
3231 remote_to_local_status(Tool::GitLab, "closed", &config),
3232 "shipped",
3233 "a closed GitLab issue belongs in the column that is actually complete"
3234 );
3235 assert_eq!(
3236 remote_to_local_status(Tool::Linear, "Done", &config),
3237 "shipped"
3238 );
3239 }
3240
3241 #[test]
3242 fn t032_export_closes_the_column_the_service_calls_complete() {
3243 // The other direction. A name comparison exported `shipped` as *open* and
3244 // closed `done` instead — the tracker and the board disagreeing about the same
3245 // issue in both directions at once.
3246 let config = split_completion_config();
3247 assert_eq!(
3248 local_to_remote_status(Tool::GitLab, "shipped", &config),
3249 "closed"
3250 );
3251 assert_eq!(
3252 local_to_remote_status(Tool::GitLab, "done", &config),
3253 "opened"
3254 );
3255 assert_eq!(
3256 local_to_remote_status(Tool::Linear, "shipped", &config),
3257 "Done"
3258 );
3259 }
3260
3261 #[test]
3262 fn t032_a_closed_remote_state_mapped_to_a_canceled_column_is_not_complete() {
3263 // T033 step 4. `canceled` is terminal but **not** complete (contract §4), so
3264 // an export that sends a `canceled` column as closed would be claiming the
3265 // work is finished when it was abandoned. The local side is the service's
3266 // answer, so it says no.
3267 let mut config = split_completion_config();
3268 config
3269 .kanban
3270 .configurations
3271 .get_mut("default")
3272 .unwrap()
3273 .category_map
3274 .insert("done".to_string(), "canceled".to_string());
3275 let board = config.default_config();
3276 // `shipped` is still `completed`, so it still exports as closed…
3277 assert_eq!(
3278 local_to_remote_status(Tool::GitLab, "shipped", &config),
3279 "closed"
3280 );
3281 // …and `done` is now `canceled`, which is terminal and not complete, so it does
3282 // not. The remote has only two states, so the honest outcome is that `canceled`
3283 // cannot be represented distinctly — and the local definition is what decides,
3284 // not the remote's vocabulary.
3285 assert!(
3286 !is_done_column(board, "done", &config),
3287 "a canceled column is not locally complete"
3288 );
3289 }
3290
3291 #[test]
3292 fn t032_the_existing_round_trips_are_unchanged() {
3293 // T032 step 5: this is a refactor of *where the decision comes from*, not a
3294 // protocol change. The default board's behaviour must be identical.
3295 let config = Config::default();
3296 assert_eq!(
3297 local_to_remote_status(Tool::GitLab, "done", &config),
3298 "closed"
3299 );
3300 assert_eq!(
3301 local_to_remote_status(Tool::GitLab, "todo", &config),
3302 "opened"
3303 );
3304 assert_eq!(
3305 remote_to_local_status(Tool::GitLab, "closed", &config),
3306 "done"
3307 );
3308 assert_eq!(
3309 remote_to_local_status(Tool::Linear, "Done", &config),
3310 "done"
3311 );
3312 // "In Progress" case-insensitively matches the board's `in progress` column,
3313 // and a name match wins over the first column. The first draft of this
3314 // assertion expected `todo` — which is the *first* column, and therefore the
3315 // answer when no name matches. It was asserting the opposite rule to the one
3316 // it claimed to pin.
3317 assert_eq!(
3318 remote_to_local_status(Tool::Linear, "In Progress", &config),
3319 "in progress",
3320 "a name match wins over the first column"
3321 );
3322 assert_eq!(
3323 remote_to_local_status(Tool::Linear, "No Such State", &config),
3324 "todo",
3325 "and with no match at all, the first column"
3326 );
3327 }
3328
3329 #[test]
3330 fn t032_the_sync_seam_holds_no_local_completion_definition() {
3331 // T032 step 4, as a structural guard. The module header says a second local
3332 // definition is a C-004 defect, and this is what keeps that from being
3333 // "improved" back in: the seam may only *translate* a remote state word, and
3334 // the completion test may only come from the service.
3335 let source = include_str!("mod.rs");
3336 // Assembled from fragments so this assertion does not match **itself**: the
3337 // first version spelled the forbidden name out in full, so the very line
3338 // asserting its absence satisfied the check. That is the grep equivalent of a
3339 // test that proves itself, and it was found because the test went red while
3340 // the function was, in fact, absent.
3341 let forbidden = ["fn remote", "status_is_done"].concat();
3342 assert!(
3343 !source.contains(&forbidden),
3344 "the dead second definition is removed and must not come back"
3345 );
3346 // The only local completion question in the module is the service call.
3347 let uses_service = source.matches("WorkflowCategory::is_complete").count();
3348 assert!(
3349 uses_service >= 2,
3350 "both `is_done_column` and `completion_column` must ask the service, found \
3351 {uses_service} uses"
3352 );
3353 }
3354
3355 // ------------------------------------------------------------------
3356 // WP08 (mission first-class-projects-01M3BYBW) — project associations
3357 // ------------------------------------------------------------------
3358
3359 /// A representable association maps, and the reverse direction is
3360 /// **local**-shaped: the frontmatter key and the id, not a copy of the tool's
3361 /// field name (T035 step 1).
3362 #[test]
3363 fn t035_a_project_association_maps_back_to_its_local_id() {
3364 for tool in [Tool::GitLab, Tool::Linear] {
3365 let remote = RemoteProject {
3366 id: "p-1".to_string(),
3367 name: Some("One".to_string()),
3368 };
3369 match map_project_from_remote(tool, &remote) {
3370 MappedProject::Remote { field, value } => {
3371 assert_eq!(field, crate::projects::PROJECTS_KEY);
3372 assert_eq!(value, "p-1", "the id, never the remote's name");
3373 }
3374 MappedProject::Unsupported(d) => {
3375 panic!("{tool}: a reported id must map back: {d}")
3376 }
3377 }
3378 }
3379 }
3380
3381 /// Every association diagnoses on both trackers, and the diagnostic names the
3382 /// **field**, the **tool** and the issue — the three things that make it
3383 /// actionable rather than a shrug (T035 step 6, C-005).
3384 ///
3385 /// Written so it still holds if a tracker joins `PROJECT_WIRE_VERIFIED`: the
3386 /// mapped branch is then the assertion, not the diagnostic's.
3387 #[test]
3388 fn t035_every_project_association_diagnoses_with_its_field_its_tool_and_its_reason() {
3389 for tool in [Tool::GitLab, Tool::Linear] {
3390 match map_project_to_remote(tool, 3, "p-7") {
3391 MappedProject::Remote { field, value } => {
3392 assert!(
3393 PROJECT_WIRE_VERIFIED.contains(&tool),
3394 "{tool}: an association mapped without a verified membership field"
3395 );
3396 assert_eq!(field, project_field(tool));
3397 assert_eq!(value, "p-7");
3398 }
3399 MappedProject::Unsupported(d) => {
3400 assert_eq!(d.tool, tool, "the diagnostic names the tool");
3401 assert_eq!(d.field, project_field(tool), "and the field");
3402 assert_eq!(
3403 d.relation_id, "3->p-7",
3404 "and the issue and the association it is about"
3405 );
3406 let text = d.to_string();
3407 for needle in [d.field.as_str(), "p-7", "#3"] {
3408 assert!(text.contains(needle), "`{needle}` missing from: {text}");
3409 }
3410 // Neither CLI has a membership verb to wait for, so the message
3411 // must not promise one — the same distinction
3412 // `RELATION_CLI_ABSENT` draws.
3413 assert!(
3414 !d.message.contains("not verified"),
3415 "{tool}: there is no membership verb to verify: {text}"
3416 );
3417 }
3418 }
3419 }
3420 // The reasons are per tool because the two CLIs differ, and they say which.
3421 assert!(project_absence_reason(Tool::GitLab).contains("--project"));
3422 assert!(project_absence_reason(Tool::Linear).contains("one `project`"));
3423 assert_ne!(
3424 project_absence_reason(Tool::GitLab),
3425 project_absence_reason(Tool::Linear)
3426 );
3427 }
3428
3429 /// An association with no id is a repair job, not a remote capability gap, and
3430 /// the local entry is not quietly sent as somebody else's project.
3431 #[test]
3432 fn t035_an_association_with_no_id_is_diagnosed_and_kept() {
3433 for tool in [Tool::GitLab, Tool::Linear] {
3434 match map_project_to_remote(tool, 4, " ") {
3435 MappedProject::Remote { .. } => panic!("{tool}: nothing to send"),
3436 MappedProject::Unsupported(d) => {
3437 assert_eq!(d.tool, tool);
3438 assert_eq!(d.field, project_field(tool));
3439 assert!(d.message.contains("no id"), "{}", d.message);
3440 assert!(
3441 d.message.contains("kept locally"),
3442 "and says the entry survives: {}",
3443 d.message
3444 );
3445 }
3446 }
3447 }
3448 }
3449
3450 /// A payload's references are read **in order**, in every shape the two trackers
3451 /// are known to use, and an entry that cannot yield an id is still reported by
3452 /// name instead of being dropped (T035 step 1, NFR-002 for the order).
3453 #[test]
3454 fn t035_remote_project_reading_keeps_order_and_accepts_every_known_shape() {
3455 let list: serde_json::Value =
3456 serde_json::from_str(r#"["p-1", {"id": "p-2", "name": "Two"}, 3]"#).unwrap();
3457 assert_eq!(
3458 remote_projects(Some(&list)),
3459 vec![
3460 RemoteProject {
3461 id: "p-1".into(),
3462 name: None
3463 },
3464 RemoteProject {
3465 id: "p-2".into(),
3466 name: Some("Two".into())
3467 },
3468 RemoteProject {
3469 id: "3".into(),
3470 name: None
3471 },
3472 ],
3473 "the payload's order is the list's order"
3474 );
3475 // Linear's single `project`, under any of the id spellings.
3476 for raw in [
3477 r#"{"identifier": "p-9"}"#,
3478 r#"{"project_id": "p-9"}"#,
3479 r#""p-9""#,
3480 ] {
3481 let value: serde_json::Value = serde_json::from_str(raw).unwrap();
3482 assert_eq!(
3483 remote_projects(Some(&value)),
3484 vec![RemoteProject {
3485 id: "p-9".into(),
3486 name: None
3487 }],
3488 "{raw}"
3489 );
3490 }
3491 // A named entry with no id survives, so the import can report it.
3492 let named: serde_json::Value =
3493 serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
3494 assert_eq!(
3495 remote_projects(Some(&named)),
3496 vec![RemoteProject {
3497 id: String::new(),
3498 name: Some("Unidentified".into())
3499 }]
3500 );
3501 // Absent, null, and shapes carrying nothing at all yield no references.
3502 assert_eq!(remote_projects(None), [] as [crate::sync::RemoteProject; 0]);
3503 assert_eq!(
3504 remote_projects(Some(&serde_json::Value::Null)),
3505 [] as [crate::sync::RemoteProject; 0]
3506 );
3507 assert_eq!(
3508 remote_projects(Some(&serde_json::json!([true, false]))),
3509 [] as [crate::sync::RemoteProject; 0]
3510 );
3511 }
3512
3513 /// An entry the remote named but did not identify cannot be matched on, and the
3514 /// diagnostic quotes the name so a reader can see which project it meant.
3515 #[test]
3516 fn t035_a_remote_project_with_no_id_is_diagnosed_never_guessed_at() {
3517 for tool in [Tool::GitLab, Tool::Linear] {
3518 let remote = RemoteProject {
3519 id: " ".to_string(),
3520 name: Some("Named but unidentified".to_string()),
3521 };
3522 match map_project_from_remote(tool, &remote) {
3523 MappedProject::Remote { .. } => {
3524 panic!("{tool}: an absent id cannot become a local association")
3525 }
3526 MappedProject::Unsupported(d) => {
3527 assert_eq!(d.tool, tool);
3528 assert_eq!(d.field, project_field(tool));
3529 assert!(d.message.contains("Named but unidentified"));
3530 assert!(d.message.contains("no id"));
3531 }
3532 }
3533 }
3534 }
3535
3536 /// A diagnostic built from **remote output** is scrubbed before it is reported
3537 /// (T036 step 6). The project name is the remote text this seam quotes, which is
3538 /// exactly how a credential could reach a report.
3539 #[test]
3540 fn t036_a_project_diagnostic_built_from_remote_output_is_scrubbed() {
3541 let mut diagnostic = RemoteMappingDiagnostic {
3542 relation_id: "1->lin-secret".to_string(),
3543 field: LINEAR_PROJECT_FIELD.to_string(),
3544 tool: Tool::Linear,
3545 message: "the remote reports #1 in project `p-1` (`lin-secret`)".to_string(),
3546 };
3547 scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
3548 assert!(
3549 diagnostic.message.contains("<token>"),
3550 "{}",
3551 diagnostic.message
3552 );
3553 assert!(!diagnostic.message.contains("lin-secret"));
3554 assert!(!diagnostic.relation_id.contains("lin-secret"));
3555
3556 // With no secret configured there is nothing to redact, and the diagnostic is
3557 // reported unchanged rather than withheld.
3558 let mut untouched = RemoteMappingDiagnostic {
3559 relation_id: "1->p-1".to_string(),
3560 field: LINEAR_PROJECT_FIELD.to_string(),
3561 tool: Tool::Linear,
3562 message: "no secret here".to_string(),
3563 };
3564 let expected = untouched.clone();
3565 scrub_diagnostic(&mut untouched, None);
3566 assert_eq!(untouched, expected);
3567 }
3568
3569 /// Neither tracker sends a membership, both adapters implement the same seam,
3570 /// and the seam itself is defined once (T036 steps 3 and 5, T038 step 6).
3571 ///
3572 /// Structural assertions on purpose: the compiler already requires `set_projects`
3573 /// on both adapters (it is a required trait method), and these keep a
3574 /// tracker-specific mapping from being added to one adapter file later.
3575 #[test]
3576 fn t036_no_tracker_sends_a_membership_and_both_adapters_share_the_seam() {
3577 for tool in [Tool::GitLab, Tool::Linear] {
3578 assert!(!PROJECT_WIRE_VERIFIED.contains(&tool));
3579 assert!(matches!(
3580 map_project_to_remote(tool, 1, "p-1"),
3581 MappedProject::Unsupported(_)
3582 ));
3583 }
3584 let source = include_str!("mod.rs");
3585 for f in [
3586 "pub fn map_project_to_remote",
3587 "pub fn map_project_from_remote",
3588 ] {
3589 assert_eq!(
3590 source.matches(&format!("{f}(")).count(),
3591 1,
3592 "{f} must be *defined* exactly once, in this module, for both trackers"
3593 );
3594 }
3595 for (name, text) in [
3596 ("glab.rs", include_str!("glab.rs")),
3597 ("linear.rs", include_str!("linear.rs")),
3598 ] {
3599 assert!(
3600 !text.contains("fn map_project_to_remote")
3601 && !text.contains("fn map_project_from_remote"),
3602 "{name} must not define its own mapping seam"
3603 );
3604 assert!(
3605 text.contains("fn set_projects"),
3606 "{name} must implement the shared membership seam"
3607 );
3608 }
3609 }
3610
3611 /// The report carries a project diagnostic with the count, on the stream and in
3612 /// the returned value, so a caller cannot report success while dropping one.
3613 #[test]
3614 fn t037_a_report_records_a_project_diagnostic_and_prints_the_field() {
3615 let mut report = SyncReport::default();
3616 let mut sink: Vec<u8> = Vec::new();
3617 report
3618 .note_project(map_project_to_remote(Tool::GitLab, 2, "p-1"), &mut sink)
3619 .unwrap();
3620 assert_eq!(report.diagnostics.len(), 1);
3621 assert_eq!(report.handled, 0);
3622 let text = String::from_utf8(sink).unwrap();
3623 assert!(text.contains("gitlab: projects"), "{text}");
3624 assert!(text.contains("p-1"), "{text}");
3625 assert_eq!(report.diagnostics[0].field, GITLAB_PROJECT_FIELD);
3626 }
3627
3628 // ---- WP08 T037–T040: the epic-reference mapping seam ----------------
3629
3630 /// A reported epic id maps back to the local reference key and id, on both
3631 /// trackers (T037 step 1, C-003).
3632 #[test]
3633 fn t037_an_epic_reference_maps_back_to_its_local_id() {
3634 for tool in [Tool::GitLab, Tool::Linear] {
3635 let remote = RemoteEpic {
3636 id: "e-1".to_string(),
3637 name: Some("One".to_string()),
3638 };
3639 match map_epic_from_remote(tool, &remote) {
3640 MappedEpic::Remote { field, value } => {
3641 assert_eq!(field, crate::epics::EPICS_KEY);
3642 assert_eq!(value, "e-1", "the id, never the remote's name");
3643 }
3644 MappedEpic::Unsupported(d) => {
3645 panic!("{tool}: a reported id must map back: {d}")
3646 }
3647 }
3648 }
3649 }
3650
3651 /// Every epic reference diagnoses on both trackers, and the diagnostic names the
3652 /// **field**, the **tool** and the issue — the three things that make it
3653 /// actionable rather than a shrug (T037 step 6, C-007).
3654 ///
3655 /// Written so it still holds if a tracker joins `EPIC_WIRE_VERIFIED`: the mapped
3656 /// branch is then the assertion, not the diagnostic's.
3657 #[test]
3658 fn t037_every_epic_reference_diagnoses_with_its_field_its_tool_and_its_reason() {
3659 for tool in [Tool::GitLab, Tool::Linear] {
3660 match map_epic_to_remote(tool, 3, "e-7") {
3661 MappedEpic::Remote { field, value } => {
3662 assert!(
3663 EPIC_WIRE_VERIFIED.contains(&tool),
3664 "{tool}: a reference mapped without a verified epic field"
3665 );
3666 assert_eq!(field, EPIC_FIELD);
3667 assert_eq!(value, "e-7");
3668 }
3669 MappedEpic::Unsupported(d) => {
3670 assert_eq!(d.tool, tool, "the diagnostic names the tool");
3671 assert_eq!(d.field, EPIC_FIELD, "and the field");
3672 assert_eq!(
3673 d.relation_id, "3->e-7",
3674 "and the issue and the reference it is about"
3675 );
3676 let text = d.to_string();
3677 for needle in [d.field.as_str(), "e-7", "#3"] {
3678 assert!(text.contains(needle), "`{needle}` missing from: {text}");
3679 }
3680 // Neither CLI has an epic verb to wait for, so the message must
3681 // not promise one.
3682 assert!(
3683 !d.message.contains("not verified"),
3684 "{tool}: there is no epic verb to verify: {text}"
3685 );
3686 }
3687 }
3688 }
3689 // The reasons are per tool because the two CLIs differ, and they say which.
3690 assert!(epic_absence_reason(Tool::GitLab).contains("epic_iid"));
3691 assert!(epic_absence_reason(Tool::Linear).contains("project"));
3692 assert_ne!(
3693 epic_absence_reason(Tool::GitLab),
3694 epic_absence_reason(Tool::Linear)
3695 );
3696 }
3697
3698 /// A reference with no id is a repair job, not a remote capability gap, and the
3699 /// local entry is not quietly sent as somebody else's epic.
3700 #[test]
3701 fn t037_an_epic_reference_with_no_id_is_diagnosed_and_kept() {
3702 for tool in [Tool::GitLab, Tool::Linear] {
3703 match map_epic_to_remote(tool, 4, " ") {
3704 MappedEpic::Remote { .. } => panic!("{tool}: nothing to send"),
3705 MappedEpic::Unsupported(d) => {
3706 assert_eq!(d.tool, tool);
3707 assert_eq!(d.field, EPIC_FIELD);
3708 assert!(d.message.contains("no id"), "{}", d.message);
3709 assert!(
3710 d.message.contains("kept locally"),
3711 "and says the entry survives: {}",
3712 d.message
3713 );
3714 }
3715 }
3716 }
3717 }
3718
3719 /// A payload's references are read **in order**, in every shape the two trackers
3720 /// are known to use, and an entry that cannot yield an id is still reported by
3721 /// name instead of being dropped (T037 step 1, NFR-002 for the order).
3722 #[test]
3723 fn t037_remote_epic_reading_keeps_order_and_accepts_every_known_shape() {
3724 let list: serde_json::Value =
3725 serde_json::from_str(r#"["e-1", {"id": "e-2", "name": "Two"}, 3]"#).unwrap();
3726 assert_eq!(
3727 remote_epics(Some(&list)),
3728 vec![
3729 RemoteEpic {
3730 id: "e-1".into(),
3731 name: None
3732 },
3733 RemoteEpic {
3734 id: "e-2".into(),
3735 name: Some("Two".into())
3736 },
3737 RemoteEpic {
3738 id: "3".into(),
3739 name: None
3740 },
3741 ],
3742 "the payload's order is the list's order"
3743 );
3744 // A single reference, under any of the id spellings.
3745 for raw in [
3746 r#"{"identifier": "e-9"}"#,
3747 r#"{"epic_id": "e-9"}"#,
3748 r#""e-9""#,
3749 ] {
3750 let value: serde_json::Value = serde_json::from_str(raw).unwrap();
3751 assert_eq!(
3752 remote_epics(Some(&value)),
3753 vec![RemoteEpic {
3754 id: "e-9".into(),
3755 name: None
3756 }],
3757 "{raw}"
3758 );
3759 }
3760 // A named entry with no id survives, so the import can report it.
3761 let named: serde_json::Value =
3762 serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
3763 assert_eq!(
3764 remote_epics(Some(&named)),
3765 vec![RemoteEpic {
3766 id: String::new(),
3767 name: Some("Unidentified".into())
3768 }]
3769 );
3770 // Absent, null, and shapes carrying nothing at all yield no references.
3771 assert_eq!(remote_epics(None), [] as [crate::sync::RemoteEpic; 0]);
3772 assert_eq!(
3773 remote_epics(Some(&serde_json::Value::Null)),
3774 [] as [crate::sync::RemoteEpic; 0]
3775 );
3776 assert_eq!(
3777 remote_epics(Some(&serde_json::json!([true, false]))),
3778 [] as [crate::sync::RemoteEpic; 0]
3779 );
3780 }
3781
3782 /// An entry the remote named but did not identify cannot be matched on, and the
3783 /// diagnostic quotes the name so a reader can see which epic it meant.
3784 #[test]
3785 fn t037_a_remote_epic_with_no_id_is_diagnosed_never_guessed_at() {
3786 for tool in [Tool::GitLab, Tool::Linear] {
3787 let remote = RemoteEpic {
3788 id: " ".to_string(),
3789 name: Some("Named but unidentified".to_string()),
3790 };
3791 match map_epic_from_remote(tool, &remote) {
3792 MappedEpic::Remote { .. } => {
3793 panic!("{tool}: an absent id cannot become a local reference")
3794 }
3795 MappedEpic::Unsupported(d) => {
3796 assert_eq!(d.tool, tool);
3797 assert_eq!(d.field, EPIC_FIELD);
3798 assert!(d.message.contains("Named but unidentified"));
3799 assert!(d.message.contains("no id"));
3800 }
3801 }
3802 }
3803 }
3804
3805 /// A diagnostic built from **remote output** is scrubbed before it is reported
3806 /// (T038 step 6). The epic name is the remote text this seam quotes, which is
3807 /// exactly how a credential could reach a report.
3808 #[test]
3809 fn t038_a_remote_epic_diagnostic_is_scrubbed() {
3810 let mut diagnostic = RemoteMappingDiagnostic {
3811 relation_id: "1->lin-secret".to_string(),
3812 field: EPIC_FIELD.to_string(),
3813 tool: Tool::Linear,
3814 message: "the remote reports #1 in epic `e-1` (`lin-secret`)".to_string(),
3815 };
3816 scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
3817 assert!(
3818 diagnostic.message.contains("<token>"),
3819 "{}",
3820 diagnostic.message
3821 );
3822 assert!(!diagnostic.message.contains("lin-secret"));
3823 assert!(!diagnostic.relation_id.contains("lin-secret"));
3824 }
3825
3826 /// Neither tracker sends an epic reference, both adapters implement the same
3827 /// seam, and the seam itself is defined once (T038 step 6).
3828 ///
3829 /// Structural assertions on purpose: the compiler already requires `set_epics` on
3830 /// both adapters (it is a required trait method), and these keep a
3831 /// tracker-specific mapping from being added to one adapter file later.
3832 #[test]
3833 fn t038_no_tracker_sends_an_epic_reference_and_both_adapters_share_the_seam() {
3834 for tool in [Tool::GitLab, Tool::Linear] {
3835 assert!(!EPIC_WIRE_VERIFIED.contains(&tool));
3836 assert!(matches!(
3837 map_epic_to_remote(tool, 1, "e-1"),
3838 MappedEpic::Unsupported(_)
3839 ));
3840 }
3841 let source = include_str!("mod.rs");
3842 for f in ["pub fn map_epic_to_remote", "pub fn map_epic_from_remote"] {
3843 assert_eq!(
3844 source.matches(&format!("{f}(")).count(),
3845 1,
3846 "{f} must be *defined* exactly once, in this module, for both trackers"
3847 );
3848 }
3849 for (name, text) in [
3850 ("glab.rs", include_str!("glab.rs")),
3851 ("linear.rs", include_str!("linear.rs")),
3852 ] {
3853 assert!(
3854 !text.contains("fn map_epic_to_remote")
3855 && !text.contains("fn map_epic_from_remote"),
3856 "{name} must not define its own mapping seam"
3857 );
3858 assert!(
3859 text.contains("fn set_epics"),
3860 "{name} must implement the shared epic seam"
3861 );
3862 }
3863 }
3864
3865 /// The report carries an epic diagnostic with the count, on the stream and in the
3866 /// returned value, so a caller cannot report success while dropping one.
3867 #[test]
3868 fn t038_a_report_records_an_epic_diagnostic_and_prints_the_field() {
3869 let mut report = SyncReport::default();
3870 let mut sink: Vec<u8> = Vec::new();
3871 report
3872 .note_epic(map_epic_to_remote(Tool::GitLab, 2, "e-1"), &mut sink)
3873 .unwrap();
3874 assert_eq!(report.diagnostics.len(), 1);
3875 assert_eq!(report.handled, 0);
3876 let text = String::from_utf8(sink).unwrap();
3877 assert!(text.contains("gitlab: epics"), "{text}");
3878 assert!(text.contains("e-1"), "{text}");
3879 assert_eq!(report.diagnostics[0].field, EPIC_FIELD);
3880 }
3881}