1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
//! External tracker sync: orchestration for `tix export` / `tix import`,
//! the [`Tool`] selector, the [`CommandRunner`] spawn seam, and the pure
//! mapping / conflict-resolution rules.
//!
//! WP05 wires the CLI to `glab` (GitLab) and `linear-cli` (Linear) under
//! `contracts/external-tools.md`. Tokens are resolved through
//! [`token::resolve`] with env precedence, injected only as an env pair (never
//! argv), and every error/report surface scrubs the secret as `<token>`.
//!
//! The orchestration lives here so it is testable at module granularity by
//! pointing PATH at fake tool scripts; [`export`] / [`import`] are pure in the
//! sense that they take the store, tool, config and an output sink, and the
//! only externally visible side effects are local issue files, `tix.toml`,
//! the tool subprocesses, and what is written to `out`.
//!
//! ## One definition of "complete", and what this module's job actually is
//!
//! **A second local completion definition in this module is a C-004 defect.** There
//! used to be one: `remote_status_is_done(status) == (status == "closed" ||
//! status == "Done")`, a helper that looked like it decided whether an issue was
//! complete and in fact decided whether a *remote* state word was one of the two
//! this module happens to send. It had **no production callers** — WP02 removed the
//! last one — and its only remaining referrer was its own unit test, which is the
//! shape dead code takes when a test keeps it alive. Removed in WP07.
//!
//! What this module legitimately owns is the **translation**: the remote trackers
//! have a closed set of states (`opened`/`closed` for GitLab, a workflow-state label
//! for Linear), so something here has to say which remote word means closed and which
//! local column to send it as. That is a protocol concern and it is this module's
//! job.
//!
//! What it does **not** own is the meaning of *complete*. That is
//! [`crate::workflow::is_completed`] and nothing else — so [`local_to_remote_status`]
//! asks the service whether a local column is complete, and
//! [`remote_to_local_status`] picks the column the service calls complete via
//! [`completion_column`].
//!
//! The distinction matters because the two can disagree. A board whose `category_map`
//! puts `completed` on `shipped` while `done_column` is `done` has a completion
//! column that is *not* the one `done_column` names: comparing names exports
//! `shipped` as open and closes `done` instead, so the tracker and the board
//! disagree about the same issue in both directions.
use HashMap;
use HashSet;
use Write;
use crate;
use crate;
use crate;
use crateStore;
pub use *;
pub use *;
pub use *;
/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
/// `FakeRunner` that records argv/env without spawning anything.
/// Common surface both tracker adapters implement so the orchestration is
/// tool-agnostic.
///
/// `status` is the *remote* state/label already computed by the pure mapping
/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
/// conveys it with `-s Done` ([`TrackerAdapter::done_via_status`]).
/// The remote fields each tracker can express for a typed relation, and the one it
/// maps every blocking edge onto.
///
/// This is the whole of the representability decision, and it is deliberately a
/// table keyed on `(Tool, RelationKind)` rather than logic spread across the two
/// adapters. Two consequences worth stating:
///
/// - **It is derived from what the adapters can actually send**, not from a guess
/// about their APIs. `linear-cli relations add -r <kind> <FROM> <TO>` is the only
/// relation verb either CLI exposes, so `blocks` maps for Linear and nothing else
/// does; `glab` exposes no relation verb at all (see `RELATION_CLI_ABSENT`).
/// - **`blocks` maps; `related`, `duplicates`, and `blocked_by` do not.** Both
/// trackers' issue-update CLIs carry a title, body, labels, assignee, and state —
/// and no field for "this issue is related to that one". Guessing otherwise
/// would mean writing a title into a field, which is not a mapping.
///
/// `blocked_by` is the inverse of `blocks`, not a third thing, so it is reported
/// against the same remote field: naming `blocking` for it would be true but would
/// send the reader looking for a relation the tracker does not have.
///
/// ## Linear is wire-verified; GitLab is verified to have no command at all
///
/// The mapping knows what each tracker's *model* can express — GitLab and Linear
/// both have blocking relations in their APIs — and the adapters drive a **CLI**,
/// so what matters is whether the CLI exposes a verb.
///
/// **Linear's does.** `linear-cli` 0.3.28 (nesszer/linear-cli, the
/// `cargo:linear-cli` crate) has `relations add --relation
/// <blocks|blocked-by|related|duplicate> <FROM> <TO>`, checked against the
/// installed binary on 2026-10-09. Four shapes parse — they fail only with
/// `No workspace selected` (exit 1, no workspace configured here):
/// `relations add -r blocks ENG-1 ENG-2`, `issues create "T" -t ENG -d D -p 2 -s
/// Todo -o json`, `issues update ENG-1 -T T -d D -s Done -o json`, and
/// `issues list -t ENG -s Todo -o json`; and two clap errors confirm the shape —
/// `linear-cli issue create "T"` (exit 2, `unrecognized subcommand 'issue'`) and
/// `linear-cli issues create --title T` (exit 2, `unexpected argument
/// '--title'`). The API path itself is unverified (no workspace), so the
/// adapter is checked at the grammar level and end to end against the fake.
///
/// Sending an unverified command line is not a conservative default; it is a
/// production failure with a confusing message. So the seam maps, and an adapter
/// sends only what a verified verb carries; **GitLab remains on the separate,
/// permanent `RELATION_CLI_ABSENT` list** below — the two answers must not blur.
///
/// The follow-up this closes: verify the verb against the real CLI, add the tool
/// to this list, and implement its `set_relations` ([`Tool::Linear`] does).
const RELATION_WIRE_VERIFIED: & = &;
/// Tools whose CLI is **verified to expose no relation command at all**.
///
/// This is a *different, permanent* answer from "not yet verified", and the two
/// must not share wording. A reader told "the verb is not verified" will reasonably
/// wait for (or go looking for) the missing command; a reader told the CLI has no
/// such command can stop.
///
/// `glab` 1.119.0, checked against the installed binary (tix #1354):
/// `glab issue update --help` lists no `--blocking` (or any relation) flag, and
/// `glab issue` has no relation/`link` subcommand. GitLab's *model* has issue links
/// and the REST API exposes them, but the CLI this adapter drives does not.
const RELATION_CLI_ABSENT: & = &;
// ---------------------------------------------------------------------------
// WP08 — the parent reference across a sync round trip
// ---------------------------------------------------------------------------
/// The CLI verbs that would carry a parent reference, per adapter.
///
/// **Empty: no adapter has a verified parent verb.** Both trackers' *models* have
/// a hierarchy — GitLab has epics and child issues, Linear has parent issues — but
/// the adapters drive a CLI, and whether `glab issue create --parent` or
/// `linear-cli issues create --parent` exists is a claim about a program this
/// repository cannot run. (Linear's `relations add` is verified for *relations*
/// and is on `RELATION_WIRE_VERIFIED`; that is a different verb and says nothing
/// about a parent, and the fake in `tests/bin/` accepts no parent flag.)
///
/// So the seam maps, and nothing is sent until a verb is verified. What *does*
/// work today — and is what the round trip covers — is that the reference
/// survives: the local parent is retained, the mapping is reported, and an import
/// restores a parent that came from the remote. Flipping a tool on here is a
/// one-line change plus its adapter implementation, once the verb is confirmed by
/// hand.
const PARENT_WIRE_VERIFIED: & = &;
/// The remote field each adapter would carry a parent in, if its verb existed.
///
/// Present so the diagnostic can *name* the thing that is missing, rather than
/// saying only that something is. A reader told "GitLab cannot express this" will
/// not go looking for the flag.
const GITLAB_PARENT_FIELD: &str = "parent";
const LINEAR_PARENT_FIELD: &str = "parent";
// ---------------------------------------------------------------------------
// WP08 (mission first-class-projects-01M3BYBW) — project associations across a
// sync round trip
// ---------------------------------------------------------------------------
//
// ## The two directions are not symmetric, and each says so
//
// **Outward: nothing leaves the machine, ever.** GitLab's and Linear's *models*
// both know what a project is; the CLIs these adapters drive do not. `glab`
// already passes the one project an issue lives in (`--project <path>`) on every
// call and exposes no membership field at all, and Linear's issue carries a
// single `project` — so neither can hold the *list* this mission's C-003
// requires. Multi-membership is the intake's named risk, and the contract's
// answer for it is C-005, not a guess: every association diagnoses, and the local
// association list is untouched. Sending one member of a list would be a silent
// drop with extra steps.
//
// **Inward: a payload that reports associations is honoured.** The read direction
// is deliberately *not* gated the way the relation mapper's is, and the reason is
// not convenience. `map_relation_from_remote` must refuse an unrecognised field
// because turning remote text into a local edge can invent a relationship no
// export ever made. A project id cannot: it is matched against the local
// **registry** before anything is written, and an id the registry does not know
// is refused with a diagnostic rather than written (C-005). So the worst an
// invented id can produce is a named diagnostic, never local data. Reading a
// field a payload reported is also not the same claim as sending one — the same
// distinction `GlabIssue::parent` already draws.
//
// This is what makes the round trip non-vacuous: the export says what cannot
// leave, the import applies what came back through the project **service**, and
// the association list is byte-stable either way (NFR-002).
/// A project reference a remote reported on one issue.
/// The remote field each adapter would carry an issue's project associations in.
///
/// The two differ because the trackers' models differ, and the diagnostic names
/// the field the reader should stop looking for:
///
/// - **GitLab** — `projects`. An issue JSON carries the one project that owns it;
/// the field a *membership list* would need is `projects`, which no `glab`
/// command exposes.
/// - **Linear** — `project`. Its issue carries exactly one project, so the field
/// that exists is named and the list it cannot hold is what diagnoses.
const GITLAB_PROJECT_FIELD: &str = "projects";
const LINEAR_PROJECT_FIELD: &str = "project";
/// Tools verified to drive project **membership** from their CLI.
///
/// **Empty, and not because the verb is merely unverified.** The relation and
/// parent tables are empty pending a verb confirmation; this one is empty because
/// neither CLI has a verb to confirm: `glab issue update --help` (1.119.0) lists
/// `--project` only as *which project owns the issue*, and Linear's issue carries
/// one `project` with no list form. So an issue with three projects cannot be
/// expressed as three of anything, and C-003's multi-membership — the intake's
/// named risk — is what makes this terminal rather than pending.
///
/// The table still exists rather than a bare `unsupported` constant so the seam
/// has the same shape as the other three, and so a future tracker that *does*
/// model membership in its CLI is a one-line addition plus its `set_projects`.
const PROJECT_WIRE_VERIFIED: & = &;
// ---------------------------------------------------------------------------
// WP08 (mission epics-work-items-01M3BYW4) — epic references across a sync
// round trip
// ---------------------------------------------------------------------------
//
// ## What the adapters can actually express
//
// **Outward: nothing leaves the machine, and that is the derived answer, not a
// placeholder.** Research R7 states GitLab's adapter has no epic concept and the
// `grep` confirms it: neither `glab` nor `linear-cli` carries an epic membership
// list. `glab`'s issue JSON names at most one epic (`epic_iid`, which this adapter
// already reads as the issue's single parent) and exposes no flag to send one;
// Linear has no epic level at all. Because the mission allows **several** epics per
// issue (C-003), an adapter that sent one member of a list would be a silent drop
// with extra steps — which C-007 forbids outright. So every reference diagnoses and
// the local list is untouched.
//
// **Inward: a payload that reports epic ids is honoured.** Like the project seam,
// the read direction is deliberately *not* gated: the id is matched against the
// local **registry** before anything is written, so the worst an invented id can
// produce is a named diagnostic, never local data (C-007).
//
// This is what makes the round trip non-vacuous: the export says what cannot leave,
// the import applies what came back through the epic **service**
// ([`Store::reference_epic`], the same path `tix edit --add-epic` takes), and the
// reference list is byte-stable either way (NFR-002).
/// An epic reference a remote reported on one issue.
/// The remote field an adapter would carry an issue's epic references in.
///
/// **One constant, not a per-tool table.** Unlike a project association — where
/// the two models genuinely name different fields (`projects` for GitLab's owning
/// project vs Linear's single `project`) — both trackers would name an epic
/// membership list `epics`, so a per-tool indirection would carry no information.
/// The per-tool difference lives in [`epic_absence_reason`], which is where it
/// actually is: `glab` names at most one epic per issue (`epic_iid`), while Linear
/// has no epic level at all.
const EPIC_FIELD: &str = "epics";
/// Tools verified to drive an epic **membership** from their CLI.
///
/// **Empty, and derived from the adapters rather than assumed.** `glab`'s issue
/// carries at most one epic (`epic_iid`) and exposes no flag to set it or to hold a
/// list; Linear has no epic level. So an issue in three epics cannot be expressed as
/// three of anything, and C-003's multi-membership is what makes this terminal
/// rather than pending — the same reasoning `PROJECT_WIRE_VERIFIED` records.
///
/// The table still exists rather than a bare `unsupported` constant so the seam has
/// the same shape as the other three, and so a future tracker that *does* model an
/// epic list in its CLI is a one-line addition plus its `set_epics`.
const EPIC_WIRE_VERIFIED: & = &;
/// The remote field GitLab's model carries an issue's health in.
///
/// Named so the diagnostic can say *what* is missing rather than only that something
/// is: a reader told "GitLab cannot carry this" will not go looking for the flag.
const GITLAB_HEALTH_FIELD: &str = "health_status";
/// The remote field Linear's model would need for an issue's health. Linear has no
/// issue level health, so the constant names the field a future adapter would have to
/// introduce — and the mapping says which of the two reasons applies.
const LINEAR_HEALTH_FIELD: &str = "health";
/// Tools whose **model has no field** for an issue's health at all.
///
/// The claim is different from "the verb is not verified", and the message says
/// which, for the same reason `RELATION_CLI_ABSENT` does: a reader who is told
/// "GitLab can express this but the verb is unverified" will look for the verb.
const HEALTH_MODEL_ABSENT: & = &;
/// Tools verified to drive the health field from their CLI.
///
/// Empty, and for the same reason `PARENT_WIRE_VERIFIED` is: whether `glab issue
/// create --health-status ...` exists is a claim about a program this repository
/// cannot run. The fakes in `tests/bin/` accept no health flag at all, which is
/// better evidence of the surface than an assumption made after it. Flipping a
/// tool on here is a one-line change plus its adapter implementation, once the
/// verb is confirmed by hand.
const HEALTH_WIRE_VERIFIED: & = &;
// ---------------------------------------------------------------------------
// WP07 (mission roadmap-initiatives-01M3C8SX) — initiatives across a sync
// round trip
// ---------------------------------------------------------------------------
//
// ## What the adapters can actually express, checked rather than assumed
//
// An initiative is a portfolio-level record, and this module's plan is explicit
// that the sync side carries **unsupported-field diagnostics only** — no adapter
// gains an initiative sender here. The representability answer is still derived
// from the CLIs the adapters drive, and the two trackers' answers differ in
// *reason* even though both end at "nothing is sent":
//
// - **GitLab: the CLI has no initiative command at all.** `glab` 1.119.0 (checked
// against the installed binary on 2026-10-09) lists no `initiative` command:
// `glab --help` has `issue` and `milestone`, and its experimental `work-items`
// tree covers epics, issues and tasks. GitLab's *model* has no initiative either.
// That is a **permanent** answer, like [`RELATION_CLI_ABSENT`], and the wording
// says so: a reader told "the verb is unverified" would wait for one, and there
// is nothing to wait for.
// - **Linear: the verb exists, but it cannot hold the record.** `linear-cli` 0.3.28
// (checked the same day) *does* expose `initiatives list|get|create|update|delete`;
// `linear-cli initiatives list -o json` parses and fails only with `No workspace
// selected` (exit 1), which is a grammar-level verification of the command line.
// So the honest statement is not "there is no verb" — it is that the
// `create`/`update` verbs take only a **name**, a **description** and a
// **status**, so they cannot carry the local record's `owner`, `priority`,
// `labels`, `target_date`, or its typed project/epic/milestone associations.
// Sending the fields they *can* take while dropping the rest would be a silent
// drop with extra steps (C-005/C-007), so the whole local record is kept.
//
// `INITIATIVE_WIRE_VERIFIED` is empty, and `initiative_absence_reason` carries the
// per-tool difference so the two answers never blur. The table exists rather than a
// bare `unsupported` constant for the same reason the parent/health tables do: a
// future tracker that models the whole record in its CLI is a one-line addition plus
// its `set_initiatives`.
/// The remote field an adapter would carry an initiative in.
///
/// Both trackers would name it `initiatives`, so — unlike a project association,
/// where the two models genuinely name different fields — a per-tool indirection
/// would carry no information. The per-tool difference lives in
/// [`initiative_absence_reason`], which is where it actually is.
const INITIATIVE_FIELD: &str = "initiatives";
/// Tools whose CLI is **verified to expose no initiative command at all**.
///
/// A *permanent* answer, distinct from "not yet verified", for the same reason
/// [`RELATION_CLI_ABSENT`] is: a reader told "the verb is unverified" will reasonably
/// wait for it, and `glab` has no such command to wait for.
const INITIATIVE_CLI_ABSENT: & = &;
/// Tools verified to drive an initiative from their CLI.
///
/// **Empty**, and deliberately so on both trackers. No adapter implements a
/// `set_initiatives`, and the one CLI that exposes initiative verbs
/// (`linear-cli`) cannot carry the record's fields — see the section header. The
/// table is the same shape as the other four so a future tracker that *does* model
/// the whole record is a one-line addition plus its adapter method.
const INITIATIVE_WIRE_VERIFIED: & = &;
const GITLAB_BLOCKING_FIELD: &str = "blocking";
const GITLAB_BLOCKING_VALUE: &str = "blocks";
const LINEAR_BLOCKING_FIELD: &str = "blocks";
const LINEAR_BLOCKING_VALUE: &str = "blocks";