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