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
53mod adapters;
54mod export;
55mod import;
56pub use adapters::*;
57pub use export::*;
58pub use import::*;
59
60pub mod beads;
61pub mod glab;
62pub mod linear;
63pub mod token;
64
65/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
66/// `FakeRunner` that records argv/env without spawning anything.
67pub trait CommandRunner: Send {
68 fn run(
69 &self,
70 program: &str,
71 args: &[String],
72 env: &[(String, String)],
73 ) -> Result<CommandOutcome>;
74
75 /// Downcasting hook so tests can inspect recorded calls on a fake.
76 fn as_any(&self) -> &dyn std::any::Any;
77}
78
79/// Common surface both tracker adapters implement so the orchestration is
80/// tool-agnostic.
81///
82/// `status` is the *remote* state/label already computed by the pure mapping
83/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
84/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
85/// conveys it with `-s Done` ([`TrackerAdapter::done_via_status`]).
86pub trait TrackerAdapter {
87 fn tool(&self) -> Tool;
88
89 /// Create a remote issue and return its remote id (GitLab `iid`, Linear
90 /// `identifier`).
91 fn create_issue(
92 &mut self,
93 title: &str,
94 body: &str,
95 labels: &[String],
96 assignee: Option<&str>,
97 priority: Option<&str>,
98 status: &str,
99 ) -> Result<String>;
100
101 /// Update an existing remote issue in place.
102 #[allow(clippy::too_many_arguments)]
103 fn update_issue(
104 &mut self,
105 remote_ref: &str,
106 title: &str,
107 body: &str,
108 labels: &[String],
109 assignee: Option<&str>,
110 priority: Option<&str>,
111 status: &str,
112 ) -> Result<()>;
113
114 /// Convey "done" (GitLab: `issue close <id>`). Only invoked when
115 /// `done_via_status` is false.
116 fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
117
118 /// List open remote issues (the import source). Closed/done issues are
119 /// excluded.
120 fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
121
122 /// Linear sets done through the status flag; GitLab needs a separate
123 /// close call.
124 fn done_via_status(&self) -> bool;
125
126 /// Send the relationships this tracker can express, after the issue itself
127 /// exists (WP07 T030/T031).
128 ///
129 /// **Required, not defaulted.** A default no-op would let a tracker be added
130 /// that silently never syncs relationships — the failure mode is a local
131 /// relation that appears to sync and does not, which is the same silent-loss
132 /// shape as a dropped edge. Two implementors is a small price for making the
133 /// omission a compile error.
134 ///
135 /// The relationships arrive already filtered by
136 /// [`map_relation_to_remote`], so an adapter never decides representability:
137 /// it sends what the shared seam says it can send, and reports the rest. A
138 /// `blocks` edge reaches here; a `related` edge never does.
139 fn set_relations(&mut self, remote_ref: &str, relations: &[MappedRelation]) -> Result<()>;
140
141 /// Send the project associations this tracker can express, after the issue
142 /// itself exists (WP08 T036).
143 ///
144 /// **Required, not defaulted**, for the same reason [`Self::set_relations`] is:
145 /// a default no-op would let a tracker be added that silently never syncs
146 /// project membership, which is the silent-loss shape C-005 forbids. Both
147 /// adapters implement `PROJECT_WIRE_VERIFIED`'s answer, which is "nothing", and
148 /// the signature is why a future tracker cannot answer with a different shape.
149 ///
150 /// The associations arrive already filtered by [`map_project_to_remote`], so an
151 /// adapter never decides representability — and while `PROJECT_WIRE_VERIFIED` is
152 /// empty it never receives a sendable value either, so both implementations
153 /// return without spawning anything. A run that cannot express membership must
154 /// send **nothing**: an empty membership flag that the tool interpreted as
155 /// "clear it" would delete remote state on a run that had nothing to say, which
156 /// is the same trap `set_relations` avoids by returning early on an empty set.
157 fn set_projects(&mut self, remote_ref: &str, projects: &[MappedProject]) -> Result<()>;
158
159 /// Send the epic references this tracker can express, after the issue itself
160 /// exists (WP08, mission `epics-work-items-01M3BYW4`).
161 ///
162 /// **Required, not defaulted**, for the reason [`Self::set_relations`] and
163 /// [`Self::set_projects`] give: a default no-op would let a tracker be added
164 /// that silently never syncs an epic reference, which is the silent-loss shape
165 /// C-007 forbids. The signature is why a future tracker cannot answer with a
166 /// different shape.
167 ///
168 /// The references arrive already filtered by [`map_epic_to_remote`], so an
169 /// adapter never decides representability. Both adapters implement
170 /// `EPIC_WIRE_VERIFIED`'s answer, which is "nothing": neither `glab` nor
171 /// `linear-cli` carries an epic membership list, so both return without spawning
172 /// anything, and an empty set must spawn nothing (an empty flag the tool read as
173 /// "clear it" would delete remote state on a run with nothing to say — the same
174 /// trap `set_relations` avoids by returning early).
175 fn set_epics(&mut self, remote_ref: &str, epics: &[MappedEpic]) -> Result<()>;
176}
177
178/// The remote fields each tracker can express for a typed relation, and the one it
179/// maps every blocking edge onto.
180///
181/// This is the whole of the representability decision, and it is deliberately a
182/// table keyed on `(Tool, RelationKind)` rather than logic spread across the two
183/// adapters. Two consequences worth stating:
184///
185/// - **It is derived from what the adapters can actually send**, not from a guess
186/// about their APIs. `linear-cli relations add -r <kind> <FROM> <TO>` is the only
187/// relation verb either CLI exposes, so `blocks` maps for Linear and nothing else
188/// does; `glab` exposes no relation verb at all (see `RELATION_CLI_ABSENT`).
189/// - **`blocks` maps; `related`, `duplicates`, and `blocked_by` do not.** Both
190/// trackers' issue-update CLIs carry a title, body, labels, assignee, and state —
191/// and no field for "this issue is related to that one". Guessing otherwise
192/// would mean writing a title into a field, which is not a mapping.
193///
194/// `blocked_by` is the inverse of `blocks`, not a third thing, so it is reported
195/// against the same remote field: naming `blocking` for it would be true but would
196/// send the reader looking for a relation the tracker does not have.
197///
198/// ## Linear is wire-verified; GitLab is verified to have no command at all
199///
200/// The mapping knows what each tracker's *model* can express — GitLab and Linear
201/// both have blocking relations in their APIs — and the adapters drive a **CLI**,
202/// so what matters is whether the CLI exposes a verb.
203///
204/// **Linear's does.** `linear-cli` 0.3.28 (nesszer/linear-cli, the
205/// `cargo:linear-cli` crate) has `relations add --relation
206/// <blocks|blocked-by|related|duplicate> <FROM> <TO>`, checked against the
207/// installed binary on 2026-10-09. Four shapes parse — they fail only with
208/// `No workspace selected` (exit 1, no workspace configured here):
209/// `relations add -r blocks ENG-1 ENG-2`, `issues create "T" -t ENG -d D -p 2 -s
210/// Todo -o json`, `issues update ENG-1 -T T -d D -s Done -o json`, and
211/// `issues list -t ENG -s Todo -o json`; and two clap errors confirm the shape —
212/// `linear-cli issue create "T"` (exit 2, `unrecognized subcommand 'issue'`) and
213/// `linear-cli issues create --title T` (exit 2, `unexpected argument
214/// '--title'`). The API path itself is unverified (no workspace), so the
215/// adapter is checked at the grammar level and end to end against the fake.
216///
217/// Sending an unverified command line is not a conservative default; it is a
218/// production failure with a confusing message. So the seam maps, and an adapter
219/// sends only what a verified verb carries; **GitLab remains on the separate,
220/// permanent `RELATION_CLI_ABSENT` list** below — the two answers must not blur.
221///
222/// The follow-up this closes: verify the verb against the real CLI, add the tool
223/// to this list, and implement its `set_relations` ([`Tool::Linear`] does).
224const RELATION_WIRE_VERIFIED: &[Tool] = &[Tool::Linear];
225
226/// Tools whose CLI is **verified to expose no relation command at all**.
227///
228/// This is a *different, permanent* answer from "not yet verified", and the two
229/// must not share wording. A reader told "the verb is not verified" will reasonably
230/// wait for (or go looking for) the missing command; a reader told the CLI has no
231/// such command can stop.
232///
233/// `glab` 1.119.0, checked against the installed binary (tix #1354):
234/// `glab issue update --help` lists no `--blocking` (or any relation) flag, and
235/// `glab issue` has no relation/`link` subcommand. GitLab's *model* has issue links
236/// and the REST API exposes them, but the CLI this adapter drives does not.
237const RELATION_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
238
239// ---------------------------------------------------------------------------
240// WP08 — the parent reference across a sync round trip
241// ---------------------------------------------------------------------------
242
243/// The CLI verbs that would carry a parent reference, per adapter.
244///
245/// **Empty: no adapter has a verified parent verb.** Both trackers' *models* have
246/// a hierarchy — GitLab has epics and child issues, Linear has parent issues — but
247/// the adapters drive a CLI, and whether `glab issue create --parent` or
248/// `linear-cli issues create --parent` exists is a claim about a program this
249/// repository cannot run. (Linear's `relations add` is verified for *relations*
250/// and is on `RELATION_WIRE_VERIFIED`; that is a different verb and says nothing
251/// about a parent, and the fake in `tests/bin/` accepts no parent flag.)
252///
253/// So the seam maps, and nothing is sent until a verb is verified. What *does*
254/// work today — and is what the round trip covers — is that the reference
255/// survives: the local parent is retained, the mapping is reported, and an import
256/// restores a parent that came from the remote. Flipping a tool on here is a
257/// one-line change plus its adapter implementation, once the verb is confirmed by
258/// hand.
259const PARENT_WIRE_VERIFIED: &[Tool] = &[];
260
261/// The remote field each adapter would carry a parent in, if its verb existed.
262///
263/// Present so the diagnostic can *name* the thing that is missing, rather than
264/// saying only that something is. A reader told "GitLab cannot express this" will
265/// not go looking for the flag.
266const GITLAB_PARENT_FIELD: &str = "parent";
267const LINEAR_PARENT_FIELD: &str = "parent";
268
269// ---------------------------------------------------------------------------
270// WP08 (mission first-class-projects-01M3BYBW) — project associations across a
271// sync round trip
272// ---------------------------------------------------------------------------
273//
274// ## The two directions are not symmetric, and each says so
275//
276// **Outward: nothing leaves the machine, ever.** GitLab's and Linear's *models*
277// both know what a project is; the CLIs these adapters drive do not. `glab`
278// already passes the one project an issue lives in (`--project <path>`) on every
279// call and exposes no membership field at all, and Linear's issue carries a
280// single `project` — so neither can hold the *list* this mission's C-003
281// requires. Multi-membership is the intake's named risk, and the contract's
282// answer for it is C-005, not a guess: every association diagnoses, and the local
283// association list is untouched. Sending one member of a list would be a silent
284// drop with extra steps.
285//
286// **Inward: a payload that reports associations is honoured.** The read direction
287// is deliberately *not* gated the way the relation mapper's is, and the reason is
288// not convenience. `map_relation_from_remote` must refuse an unrecognised field
289// because turning remote text into a local edge can invent a relationship no
290// export ever made. A project id cannot: it is matched against the local
291// **registry** before anything is written, and an id the registry does not know
292// is refused with a diagnostic rather than written (C-005). So the worst an
293// invented id can produce is a named diagnostic, never local data. Reading a
294// field a payload reported is also not the same claim as sending one — the same
295// distinction `GlabIssue::parent` already draws.
296//
297// This is what makes the round trip non-vacuous: the export says what cannot
298// leave, the import applies what came back through the project **service**, and
299// the association list is byte-stable either way (NFR-002).
300
301/// A project reference a remote reported on one issue.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct RemoteProject {
304 /// The remote's project identity. It is the **only** thing an import matches
305 /// on (C-002): an id is allocated once and never changes, so a remote that
306 /// renames a project still names the same one.
307 pub id: String,
308 /// The remote's display name for the project, when it sent one.
309 ///
310 /// Carried for the diagnostics — a reader recognises "the remote calls it
311 /// *Platform hardening*" — and **never** matched on. The local display name
312 /// stays the registry's (C-002), so a remote rename cannot change it, and it
313 /// is the only remote text in a diagnostic, which is why that diagnostic is
314 /// scrubbed.
315 pub name: Option<String>,
316}
317
318/// The remote field each adapter would carry an issue's project associations in.
319///
320/// The two differ because the trackers' models differ, and the diagnostic names
321/// the field the reader should stop looking for:
322///
323/// - **GitLab** — `projects`. An issue JSON carries the one project that owns it;
324/// the field a *membership list* would need is `projects`, which no `glab`
325/// command exposes.
326/// - **Linear** — `project`. Its issue carries exactly one project, so the field
327/// that exists is named and the list it cannot hold is what diagnoses.
328const GITLAB_PROJECT_FIELD: &str = "projects";
329const LINEAR_PROJECT_FIELD: &str = "project";
330
331/// Tools verified to drive project **membership** from their CLI.
332///
333/// **Empty, and not because the verb is merely unverified.** The relation and
334/// parent tables are empty pending a verb confirmation; this one is empty because
335/// neither CLI has a verb to confirm: `glab issue update --help` (1.119.0) lists
336/// `--project` only as *which project owns the issue*, and Linear's issue carries
337/// one `project` with no list form. So an issue with three projects cannot be
338/// expressed as three of anything, and C-003's multi-membership — the intake's
339/// named risk — is what makes this terminal rather than pending.
340///
341/// The table still exists rather than a bare `unsupported` constant so the seam
342/// has the same shape as the other three, and so a future tracker that *does*
343/// model membership in its CLI is a one-line addition plus its `set_projects`.
344const PROJECT_WIRE_VERIFIED: &[Tool] = &[];
345
346// ---------------------------------------------------------------------------
347// WP08 (mission epics-work-items-01M3BYW4) — epic references across a sync
348// round trip
349// ---------------------------------------------------------------------------
350//
351// ## What the adapters can actually express
352//
353// **Outward: nothing leaves the machine, and that is the derived answer, not a
354// placeholder.** Research R7 states GitLab's adapter has no epic concept and the
355// `grep` confirms it: neither `glab` nor `linear-cli` carries an epic membership
356// list. `glab`'s issue JSON names at most one epic (`epic_iid`, which this adapter
357// already reads as the issue's single parent) and exposes no flag to send one;
358// Linear has no epic level at all. Because the mission allows **several** epics per
359// issue (C-003), an adapter that sent one member of a list would be a silent drop
360// with extra steps — which C-007 forbids outright. So every reference diagnoses and
361// the local list is untouched.
362//
363// **Inward: a payload that reports epic ids is honoured.** Like the project seam,
364// the read direction is deliberately *not* gated: the id is matched against the
365// local **registry** before anything is written, so the worst an invented id can
366// produce is a named diagnostic, never local data (C-007).
367//
368// This is what makes the round trip non-vacuous: the export says what cannot leave,
369// the import applies what came back through the epic **service**
370// ([`Store::reference_epic`], the same path `tix edit --add-epic` takes), and the
371// reference list is byte-stable either way (NFR-002).
372
373/// An epic reference a remote reported on one issue.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct RemoteEpic {
376 /// The remote's epic identity. It is the **only** thing an import matches on
377 /// (C-003): an epic id is allocated once and never changes, so a remote that
378 /// renames an epic still names the same one.
379 pub id: String,
380 /// The remote's display name for the epic, when it sent one.
381 ///
382 /// Carried for the diagnostics — a reader recognises "the remote calls it
383 /// *Sync hardening*" — and **never** matched on. The local display name stays
384 /// the registry's (C-003), so a remote rename cannot change it; it is the only
385 /// remote text in a diagnostic, which is why that diagnostic is scrubbed.
386 pub name: Option<String>,
387}
388
389/// The remote field an adapter would carry an issue's epic references in.
390///
391/// **One constant, not a per-tool table.** Unlike a project association — where
392/// the two models genuinely name different fields (`projects` for GitLab's owning
393/// project vs Linear's single `project`) — both trackers would name an epic
394/// membership list `epics`, so a per-tool indirection would carry no information.
395/// The per-tool difference lives in [`epic_absence_reason`], which is where it
396/// actually is: `glab` names at most one epic per issue (`epic_iid`), while Linear
397/// has no epic level at all.
398const EPIC_FIELD: &str = "epics";
399
400/// Tools verified to drive an epic **membership** from their CLI.
401///
402/// **Empty, and derived from the adapters rather than assumed.** `glab`'s issue
403/// carries at most one epic (`epic_iid`) and exposes no flag to set it or to hold a
404/// list; Linear has no epic level. So an issue in three epics cannot be expressed as
405/// three of anything, and C-003's multi-membership is what makes this terminal
406/// rather than pending — the same reasoning `PROJECT_WIRE_VERIFIED` records.
407///
408/// The table still exists rather than a bare `unsupported` constant so the seam has
409/// the same shape as the other three, and so a future tracker that *does* model an
410/// epic list in its CLI is a one-line addition plus its `set_epics`.
411const EPIC_WIRE_VERIFIED: &[Tool] = &[];
412
413/// The remote field GitLab's model carries an issue's health in.
414///
415/// Named so the diagnostic can say *what* is missing rather than only that something
416/// is: a reader told "GitLab cannot carry this" will not go looking for the flag.
417const GITLAB_HEALTH_FIELD: &str = "health_status";
418
419/// The remote field Linear's model would need for an issue's health. Linear has no
420/// issue level health, so the constant names the field a future adapter would have to
421/// introduce — and the mapping says which of the two reasons applies.
422const LINEAR_HEALTH_FIELD: &str = "health";
423
424/// Tools whose **model has no field** for an issue's health at all.
425///
426/// The claim is different from "the verb is not verified", and the message says
427/// which, for the same reason `RELATION_CLI_ABSENT` does: a reader who is told
428/// "GitLab can express this but the verb is unverified" will look for the verb.
429const HEALTH_MODEL_ABSENT: &[Tool] = &[Tool::Linear];
430
431/// Tools verified to drive the health field from their CLI.
432///
433/// Empty, and for the same reason `PARENT_WIRE_VERIFIED` is: whether `glab issue
434/// create --health-status ...` exists is a claim about a program this repository
435/// cannot run. The fakes in `tests/bin/` accept no health flag at all, which is
436/// better evidence of the surface than an assumption made after it. Flipping a
437/// tool on here is a one-line change plus its adapter implementation, once the
438/// verb is confirmed by hand.
439const HEALTH_WIRE_VERIFIED: &[Tool] = &[];
440
441// ---------------------------------------------------------------------------
442// WP07 (mission roadmap-initiatives-01M3C8SX) — initiatives across a sync
443// round trip
444// ---------------------------------------------------------------------------
445//
446// ## What the adapters can actually express, checked rather than assumed
447//
448// An initiative is a portfolio-level record, and this module's plan is explicit
449// that the sync side carries **unsupported-field diagnostics only** — no adapter
450// gains an initiative sender here. The representability answer is still derived
451// from the CLIs the adapters drive, and the two trackers' answers differ in
452// *reason* even though both end at "nothing is sent":
453//
454// - **GitLab: the CLI has no initiative command at all.** `glab` 1.119.0 (checked
455// against the installed binary on 2026-10-09) lists no `initiative` command:
456// `glab --help` has `issue` and `milestone`, and its experimental `work-items`
457// tree covers epics, issues and tasks. GitLab's *model* has no initiative either.
458// That is a **permanent** answer, like [`RELATION_CLI_ABSENT`], and the wording
459// says so: a reader told "the verb is unverified" would wait for one, and there
460// is nothing to wait for.
461// - **Linear: the verb exists, but it cannot hold the record.** `linear-cli` 0.3.28
462// (checked the same day) *does* expose `initiatives list|get|create|update|delete`;
463// `linear-cli initiatives list -o json` parses and fails only with `No workspace
464// selected` (exit 1), which is a grammar-level verification of the command line.
465// So the honest statement is not "there is no verb" — it is that the
466// `create`/`update` verbs take only a **name**, a **description** and a
467// **status**, so they cannot carry the local record's `owner`, `priority`,
468// `labels`, `target_date`, or its typed project/epic/milestone associations.
469// Sending the fields they *can* take while dropping the rest would be a silent
470// drop with extra steps (C-005/C-007), so the whole local record is kept.
471//
472// `INITIATIVE_WIRE_VERIFIED` is empty, and `initiative_absence_reason` carries the
473// per-tool difference so the two answers never blur. The table exists rather than a
474// bare `unsupported` constant for the same reason the parent/health tables do: a
475// future tracker that models the whole record in its CLI is a one-line addition plus
476// its `set_initiatives`.
477
478/// The remote field an adapter would carry an initiative in.
479///
480/// Both trackers would name it `initiatives`, so — unlike a project association,
481/// where the two models genuinely name different fields — a per-tool indirection
482/// would carry no information. The per-tool difference lives in
483/// [`initiative_absence_reason`], which is where it actually is.
484const INITIATIVE_FIELD: &str = "initiatives";
485
486/// Tools whose CLI is **verified to expose no initiative command at all**.
487///
488/// A *permanent* answer, distinct from "not yet verified", for the same reason
489/// [`RELATION_CLI_ABSENT`] is: a reader told "the verb is unverified" will reasonably
490/// wait for it, and `glab` has no such command to wait for.
491const INITIATIVE_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
492
493/// Tools verified to drive an initiative from their CLI.
494///
495/// **Empty**, and deliberately so on both trackers. No adapter implements a
496/// `set_initiatives`, and the one CLI that exposes initiative verbs
497/// (`linear-cli`) cannot carry the record's fields — see the section header. The
498/// table is the same shape as the other four so a future tracker that *does* model
499/// the whole record is a one-line addition plus its adapter method.
500const INITIATIVE_WIRE_VERIFIED: &[Tool] = &[];
501
502const GITLAB_BLOCKING_FIELD: &str = "blocking";
503const GITLAB_BLOCKING_VALUE: &str = "blocks";
504const LINEAR_BLOCKING_FIELD: &str = "blocks";
505const LINEAR_BLOCKING_VALUE: &str = "blocks";
506
507#[cfg(test)]
508mod tests;