use std::io::Write;
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::model::{Issue, Status};
use crate::storage::Store;
pub mod glab;
pub mod linear;
pub mod token;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
GitLab,
Linear,
}
impl Tool {
#[must_use]
pub fn from_target(target: &str) -> Result<Tool> {
match target {
"gitlab" => Ok(Tool::GitLab),
"linear" => Ok(Tool::Linear),
other => Err(TixError::InvalidInput(format!(
"unknown --target '{other}' — use gitlab or linear"
))),
}
}
#[must_use]
pub fn token_var(self) -> &'static str {
match self {
Tool::GitLab => "GITLAB_TOKEN",
Tool::Linear => "LINEAR_TOKEN",
}
}
#[must_use]
pub fn binary(self) -> &'static str {
match self {
Tool::GitLab => "glab",
Tool::Linear => "linear-cli",
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Tool::GitLab => "gitlab",
Tool::Linear => "linear",
}
}
}
impl std::fmt::Display for Tool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutcome {
pub success: bool,
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl CommandOutcome {
#[must_use]
pub fn text(&self) -> &str {
if self.success {
&self.stdout
} else if !self.stderr.trim().is_empty() {
&self.stderr
} else {
&self.stdout
}
}
}
pub trait CommandRunner: Send {
fn run(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
) -> Result<CommandOutcome>;
fn as_any(&self) -> &dyn std::any::Any;
}
pub struct ProcessRunner;
impl CommandRunner for ProcessRunner {
fn run(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
) -> Result<CommandOutcome> {
use std::process::Command;
let mut cmd = Command::new(program);
cmd.args(args);
for (key, value) in env {
cmd.env(key, value);
}
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
TixError::External(not_found_message(program))
} else {
TixError::External(format!("`{program}` failed to start: {e}"))
}
})?;
Ok(CommandOutcome {
success: output.status.success(),
status: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub trait TrackerAdapter {
fn tool(&self) -> Tool;
fn create_issue(
&mut self,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<String>;
#[allow(clippy::too_many_arguments)]
fn update_issue(
&mut self,
remote_ref: &str,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<()>;
fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
fn done_via_status(&self) -> bool;
fn set_relations(&mut self, remote_ref: &str, relations: &[MappedRelation]) -> Result<()>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteIssue {
pub id: String,
pub title: String,
pub description: String,
pub labels: Vec<String>,
pub state: String,
pub updated_at: Option<String>,
pub assignee: Option<String>,
pub parent: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncReport {
pub handled: usize,
pub diagnostics: Vec<RemoteMappingDiagnostic>,
pub skipped: usize,
}
impl SyncReport {
fn note(&mut self, mapped: MappedRelation, out: &mut dyn Write) -> std::io::Result<()> {
if let MappedRelation::Unsupported(diagnostic) = mapped {
writeln!(out, "{diagnostic}")?;
self.diagnostics.push(diagnostic);
}
Ok(())
}
fn note_parent(&mut self, mapped: MappedParent, out: &mut dyn Write) -> std::io::Result<()> {
if let MappedParent::Unsupported(diagnostic) = mapped {
writeln!(out, "{diagnostic}")?;
self.diagnostics.push(diagnostic);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteMappingDiagnostic {
pub relation_id: String,
pub field: String,
pub tool: Tool,
pub message: String,
}
impl std::fmt::Display for RemoteMappingDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} cannot represent this relationship ({}) — {}",
self.tool.as_str(),
self.field,
self.relation_id,
self.message
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedRelation {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
const RELATION_WIRE_VERIFIED: &[Tool] = &[];
const PARENT_WIRE_VERIFIED: &[Tool] = &[];
const GITLAB_PARENT_FIELD: &str = "parent";
const LINEAR_PARENT_FIELD: &str = "parent";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedParent {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
#[must_use]
pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
let Some(parent) = parent else {
return MappedParent::Remote {
field: parent_field(tool).to_string(),
value: String::new(),
};
};
let field = parent_field(tool);
if PARENT_WIRE_VERIFIED.contains(&tool) {
return MappedParent::Remote {
field: field.to_string(),
value: format!("#{parent}"),
};
}
MappedParent::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{issue_number}->{parent}"),
field: field.to_string(),
tool,
message: format!(
"#{issue_number}'s parent #{parent} is expressible in {}'s model as \
`{field}`, but the CLI verb that would carry it is not verified, so \
nothing is sent; the local reference is kept",
tool.as_str()
),
})
}
fn parent_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_PARENT_FIELD,
Tool::Linear => LINEAR_PARENT_FIELD,
}
}
const GITLAB_BLOCKING_FIELD: &str = "blocking";
const GITLAB_BLOCKING_VALUE: &str = "blocks";
const LINEAR_BLOCKING_FIELD: &str = "blocks";
const LINEAR_BLOCKING_VALUE: &str = "blocks";
#[must_use]
pub fn map_relation_to_remote(
tool: Tool,
relation: &crate::relationships::Relationship,
) -> MappedRelation {
use crate::relationships::RelationKind;
let (field, value) = match tool {
Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
};
let model_supports = matches!(
relation.kind,
RelationKind::Blocks | RelationKind::BlockedBy
);
if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
return MappedRelation::Remote {
field: field.to_string(),
value: format!("{value} #{}", relation.to),
};
}
if model_supports {
return MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation.id.clone(),
field: field.to_string(),
tool,
message: format!(
"`{}` is expressible in {}'s model as `{field}`, but the CLI verb \\
that would carry it is not verified, so nothing is sent; the local \\
edge is kept",
relation.kind.as_str(),
tool.as_str()
),
});
}
MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation.id.clone(),
field: field.to_string(),
tool,
message: format!(
"`{}` is a local-only relation; {} has no field for it, so the local \
edge is kept and the remote one is not created",
relation.kind.as_str(),
tool.as_str()
),
})
}
#[must_use]
pub fn map_relation_from_remote(
tool: Tool,
field: &str,
value: &str,
relation_id: &str,
) -> MappedRelation {
let expected = match tool {
Tool::GitLab => GITLAB_BLOCKING_FIELD,
Tool::Linear => LINEAR_BLOCKING_FIELD,
};
if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
if let Some(target) = value
.split_whitespace()
.last()
.and_then(|t| t.strip_prefix('#'))
.and_then(|t| t.parse::<u32>().ok())
{
return MappedRelation::Remote {
field: "kind".to_string(),
value: format!("blocks #{target}"),
};
}
return MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation_id.to_string(),
field: field.to_string(),
tool,
message: format!(
"`{value}` is not a target issue number, so the remote relation \
cannot be read back; no local edge was created"
),
});
}
MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation_id.to_string(),
field: field.to_string(),
tool,
message: format!(
"`{field}` is not a relation field {} writes, so it is ignored; the \
local relationship set is unchanged",
tool.as_str()
),
})
}
#[must_use]
pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
let board = config.default_config();
match tool {
Tool::GitLab => {
if is_done_column(board, local_status, config) {
"closed".into()
} else {
"opened".into()
}
}
Tool::Linear => {
if is_done_column(board, local_status, config) {
"Done".into()
} else {
first_work_column(board, config).to_string()
}
}
}
}
#[must_use]
pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
let board = config.default_config();
let done = completion_column(board, config);
match tool {
Tool::GitLab => {
if remote_status == "closed" {
done
} else {
board
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".into())
}
}
Tool::Linear => {
if remote_status == "Done" {
done
} else {
board
.columns
.iter()
.find(|c| c.eq_ignore_ascii_case(remote_status))
.cloned()
.unwrap_or_else(|| {
board
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".into())
})
}
}
}
}
fn is_done_column(board: &BoardConfiguration, local_status: &str, config: &Config) -> bool {
let resolution =
crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
resolution
.resolve(local_status)
.is_some_and(crate::workflow::WorkflowCategory::is_complete)
}
fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
crate::workflow::completion_column_or_default(board, config)
}
fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
let done = crate::workflow::completion_column_or_default(board, config);
board
.columns
.iter()
.find(|c| c.as_str() != done)
.map_or("done", String::as_str)
}
#[must_use]
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let mp = (m + 9) % 12; let doy = i64::from((153 * mp + 2) / 5 + d - 1); let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
#[must_use]
pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
let mut parts = ymd.trim().split('-');
let y: i64 = parts.next()?.parse().ok()?;
let m: u32 = parts.next()?.parse().ok()?;
let d: u32 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some(days_from_civil(y, m, d))
}
#[must_use]
pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
let (date, time) = input.trim().split_once(['T', ' '])?;
let days = date_to_epoch_days(date)?;
let (time, zone) = split_zone(time);
let mut hms = time.split(':');
let hh: i64 = hms.next()?.parse().ok()?;
let mm: i64 = hms.next()?.parse().ok()?;
let ss_part = hms.next()?;
let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
return None;
}
let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
if let Some((zh, zm)) = zone {
secs -= zh * 3600 + zm * 60;
}
Some(secs)
}
fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
if time.ends_with('Z') || time.ends_with('z') {
return (&time[..time.len() - 1], Some((0, 0)));
}
match time.rfind(['+', '-']) {
Some(i) if i > 0 => {
let (bare, zone) = time.split_at(i);
let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
let digits = &zone[1..];
let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
(h.parse::<i64>().ok(), m.parse::<i64>().ok())
} else if digits.len() == 2 {
(digits.parse::<i64>().ok(), Some(0))
} else if digits.len() >= 3 {
let (h, m) = digits.split_at(digits.len() - 2);
(h.parse::<i64>().ok(), m.parse::<i64>().ok())
} else {
(None, None)
};
match (zh, zm) {
(Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
_ => (bare, None),
}
}
Some(_) | None => (time, None),
}
}
#[must_use]
pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
return false;
};
let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
remote > local_end_of_day
}
#[must_use]
pub fn scrub_secret(text: &str, secret: &str) -> String {
if secret.is_empty() {
return text.to_string();
}
text.replace(secret, "<token>")
}
fn not_found_message(program: &str) -> String {
match program {
"glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
.to_string(),
_ => format!("required tool not found: {program} — install it and retry"),
}
}
pub fn export(
store: &Store,
tool: Tool,
config: &Config,
dry_run: bool,
out: &mut dyn Write,
) -> Result<SyncReport> {
let mut adapter = adapter_for(tool, config)?;
let mut report = SyncReport::default();
let resolution = crate::workflow::resolution(config, config.default_config(), "default");
if dry_run {
for issue in store.list()? {
match remote_ref(tool, &issue) {
Some(r) => {
writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
}
None => {
writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
}
}
}
writeln!(out, "dry run — {tool} CLI was not invoked")?;
report.handled = store.list()?.len();
for issue in store.list()? {
for rel in store.relationships_for(issue.number)? {
report.note(map_relation_to_remote(tool, &rel), out)?;
}
if issue.parent.is_some() {
report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
}
}
return Ok(report);
}
let mut handled = 0;
for mut issue in store.list()? {
let remote_status = local_to_remote_status(tool, issue.status.display(), config);
let labels: Vec<String> = issue.labels.clone();
let assignee = issue.assignee.as_deref();
let priority = issue.priority.to_string();
match remote_ref(tool, &issue) {
Some(r) => {
let r = r.to_string();
adapter.update_issue(
&r,
&issue.title,
&issue.body,
&labels,
assignee,
Some(&priority),
&remote_status,
)?;
if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
{
adapter.mark_done(&r)?;
}
writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
}
None => {
let id = adapter.create_issue(
&issue.title,
&issue.body,
&labels,
assignee,
Some(&priority),
&remote_status,
)?;
if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
{
adapter.mark_done(&id)?;
}
match tool {
Tool::GitLab => issue.gitlab_id = Some(id.clone()),
Tool::Linear => issue.linear_id = Some(id.clone()),
}
store.update(&issue.filename, &issue)?;
writeln!(
out,
"created {tool} issue {id} \"{}\" (remote id recorded)",
issue.title
)?;
}
}
report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
let remote = match remote_ref(tool, &issue) {
Some(r) => r.to_string(),
None => match tool {
Tool::GitLab => issue.gitlab_id.clone(),
Tool::Linear => issue.linear_id.clone(),
}
.unwrap_or_default(),
};
if !remote.is_empty() {
let mut sendable = Vec::new();
for rel in store.relationships_for(issue.number)? {
match map_relation_to_remote(tool, &rel) {
MappedRelation::Remote { field, value } => {
writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
sendable.push(MappedRelation::Remote { field, value });
}
unsupported @ MappedRelation::Unsupported(_) => {
report.note(unsupported, out)?
}
}
}
adapter.set_relations(&remote, &sendable)?;
}
handled += 1;
}
report.handled = handled;
writeln!(
out,
"exported {handled} issue(s) to {tool} ({} relationship(s) not representable)",
report.diagnostics.len()
)?;
Ok(report)
}
pub fn import(
store: &Store,
tool: Tool,
config: &Config,
out: &mut dyn Write,
policy: crate::triage::import_policy::ImportPolicy,
) -> Result<SyncReport> {
let mut adapter = adapter_for(tool, config)?;
import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
}
pub fn import_with_adapter(
store: &Store,
tool: Tool,
config: &Config,
out: &mut dyn Write,
policy: crate::triage::import_policy::ImportPolicy,
adapter: &mut dyn TrackerAdapter,
) -> Result<SyncReport> {
let router = crate::triage::import_policy::resolve(config, policy)?;
let remote_issues = adapter.list_open()?;
let mut report = SyncReport::default();
let mut handled = 0;
let mut imported: Vec<(String, u32)> = Vec::new();
let mut claims: Vec<(String, String)> = Vec::new();
for remote in remote_issues.clone() {
if let Some(parent) = remote.parent.clone() {
claims.push((remote.id.clone(), parent));
}
let tracked = store
.list()?
.into_iter()
.find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
match tracked {
None => {
let requested = remote_to_local_status(tool, &remote.state, config);
let mapping = router.place(&remote.state, &requested);
let mut issue = Issue {
number: 0,
title: remote.title.clone(),
status: Status::new(mapping.landed.clone()),
labels: remote.labels.clone(),
priority: crate::model::Priority::Medium,
assignee: remote.assignee.clone(),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: crate::model::today(),
updated: crate::model::today(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
parent: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
match tool {
Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
Tool::Linear => issue.linear_id = Some(remote.id.clone()),
}
let (_, resolution) = crate::workflow::active_resolution(config);
if resolution.resolve(issue.status.display()).is_none() {
writeln!(
out,
"skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
remote.id, remote.title, issue.status, resolution.board
)?;
report.skipped += 1;
continue;
}
let path = store.create(&mut issue, &remote.description, None)?;
imported.push((remote.id.clone(), issue.number));
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let clause = mapping.report_clause();
writeln!(
out,
"imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
remote.id, remote.title, issue.number, issue.status
)?;
handled += 1;
}
Some(local) => {
if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
let backup = format!("{}.bak", local.filename);
std::fs::copy(
store.issues_dir.join(&local.filename),
store.issues_dir.join(&backup),
)?;
let local_status = local.status.clone();
let mut overwritten = local;
let keep_created = overwritten.created.clone();
overwritten.title = remote.title.clone();
overwritten.body = remote.description.clone();
overwritten.labels = remote.labels.clone();
let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
let (_, resolution) = crate::workflow::active_resolution(config);
let transition = crate::workflow::validate_transition(
&overwritten,
incoming.display(),
&resolution,
config,
None,
);
if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
overwritten.status = local_status.clone();
overwritten.title = remote.title.clone();
overwritten.body = remote.description.clone();
overwritten.labels = remote.labels.clone();
if let Some(a) = &remote.assignee {
overwritten.assignee = Some(a.clone());
}
overwritten.created = keep_created;
overwritten.bump_updated();
let filename = overwritten.filename.clone();
store.update(&filename, &overwritten)?;
writeln!(
out,
"overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
(kept {})",
remote.id, overwritten.title, overwritten.status
)?;
report.skipped += 1;
handled += 1;
continue;
}
overwritten.status = incoming;
if let Some(a) = &remote.assignee {
overwritten.assignee = Some(a.clone());
}
overwritten.created = keep_created;
overwritten.bump_updated();
let filename = overwritten.filename.clone();
let guard = crate::commands::mv::blocked_move_guard(
config,
store,
&overwritten,
overwritten.status.display(),
)?;
if let Some(warning) = guard.warning() {
overwritten.history.append_now(
&crate::storage::actor_identity(),
crate::model::HistoryEventKind::BlockedMoveAcknowledged,
&format!("imported to {}: {warning}", overwritten.status.display()),
);
}
store.update(&filename, &overwritten)?;
writeln!(
out,
"overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup})",
remote.id, overwritten.title
)?;
handled += 1;
} else {
writeln!(
out,
"kept {tool} issue {} \"{}\" (local updated later or equal)",
remote.id, remote.title
)?;
}
}
}
}
restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
writeln!(out, "imported {handled} {tool} issue(s)")?;
report.handled = handled;
Ok(report)
}
fn restore_imported_parents(
store: &Store,
tool: Tool,
claims: &[(String, String)],
imported: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
for (child_remote, parent_remote) in claims {
let child = imported
.iter()
.find(|(id, _)| id == child_remote)
.map(|(_, number)| *number);
let parent = imported
.iter()
.find(|(id, _)| id == parent_remote)
.map(|(_, number)| *number);
let (Some(child), Some(parent)) = (child, parent) else {
report.note_parent(
MappedParent::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{child_remote}->{parent_remote}"),
field: parent_field(tool).to_string(),
tool,
message: format!(
"remote issue {child_remote} reports parent {parent_remote}, \
which this import did not create — it may be closed, \
filtered out, or in another project; no local reference is \
written, so the issue stays parentless rather than \
unresolved"
),
}),
out,
)?;
continue;
};
let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
match store.set_parent(&child_sel, &parent_sel) {
Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
Err(e) => {
writeln!(
out,
"could not restore the parent of {tool} issue #{child}: {e}; the \
reference was not written"
)?;
report.diagnostics.push(RemoteMappingDiagnostic {
relation_id: format!("{child}->{parent}"),
field: parent_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{child} under #{parent}, but the hierarchy \
service refused it: {e}"
),
});
}
}
}
Ok(())
}
fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
match tool {
Tool::GitLab => issue.gitlab_id.as_deref(),
Tool::Linear => issue.linear_id.as_deref(),
}
}
fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
let mut warn = |w: &str| eprintln!("{w}");
let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
let secret = resolution.secret().map(str::to_string);
match tool {
Tool::GitLab => {
let rc = config.export.gitlab.as_ref().ok_or_else(|| {
TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
})?;
let project = rc.project.clone().ok_or_else(|| {
TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
})?;
Ok(Box::new(glab::GitLabAdapter::new(
ProcessRunner,
secret,
rc.base_url.clone(),
project,
)))
}
Tool::Linear => {
let rc = config.export.linear.as_ref().ok_or_else(|| {
TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
})?;
let team = rc.team.clone().ok_or_else(|| {
TixError::Config(
"`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
.to_string(),
)
})?;
Ok(Box::new(linear::LinearAdapter::new(
ProcessRunner,
secret,
team,
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_parsing_accepts_only_the_two_contract_targets() {
assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
assert!(Tool::from_target("github").is_err());
assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
assert_eq!(Tool::GitLab.binary(), "glab");
assert_eq!(Tool::Linear.binary(), "linear-cli");
}
#[test]
fn gitlab_status_mapping() {
let config = Config::default();
assert_eq!(
local_to_remote_status(Tool::GitLab, "todo", &config),
"opened"
);
assert_eq!(
local_to_remote_status(Tool::GitLab, "done", &config),
"closed"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "opened", &config),
"todo"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "closed", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "anything-else", &config),
"todo"
);
}
#[test]
fn linear_status_mapping() {
let config = Config::default();
assert_eq!(
local_to_remote_status(Tool::Linear, "todo", &config),
"todo"
);
assert_eq!(
local_to_remote_status(Tool::Linear, "in review", &config),
"todo"
);
assert_eq!(
local_to_remote_status(Tool::Linear, "done", &config),
"Done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "In Progress", &config),
"in progress"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Done", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Unknown", &config),
"todo"
);
}
#[test]
fn days_from_civil_roundtrips_civil_from_days() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(days_from_civil(2000, 1, 1), 10957);
assert_eq!(days_from_civil(1970, 1, 2), 1);
assert_eq!(days_from_civil(2026, 5, 11), 20584);
}
#[test]
fn date_to_epoch_days_parses_only_yyyymmdd() {
assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
assert_eq!(date_to_epoch_days("not-a-date"), None);
assert_eq!(date_to_epoch_days("2026-13-01"), None);
assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
}
#[test]
fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
let day = days_from_civil(2026, 5, 11) * 86_400;
assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
assert_eq!(
iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
Some(day - 7200)
);
assert_eq!(
iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
Some(day + 19_800)
);
assert_eq!(
iso_to_epoch_secs("2026-05-11T10:30:00Z"),
Some(day + 37_800)
);
assert_eq!(iso_to_epoch_secs("garbage"), None);
}
#[test]
fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
assert!(!is_remote_newer("not-a-time", "2026-05-11"));
}
#[test]
fn scrub_replaces_secret_everywhere_and_preserves_text() {
assert_eq!(
scrub_secret("token=sekrit end", "sekrit"),
"token=<token> end"
);
assert_eq!(
scrub_secret("token=sekrit token=sekrit", "sekrit"),
"token=<token> token=<token>"
);
assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
assert_eq!(scrub_secret("empty secret", ""), "empty secret");
}
fn rel(kind: crate::relationships::RelationKind) -> crate::relationships::Relationship {
let mut r = crate::relationships::Relationship::new(1, 2, kind, "Ship it");
r.id = "rel_TEST".to_string();
r
}
#[test]
fn every_relationship_diagnoses_with_its_field_its_tool_and_its_reason() {
for tool in [Tool::GitLab, Tool::Linear] {
for kind in [
crate::relationships::RelationKind::Blocks,
crate::relationships::RelationKind::BlockedBy,
crate::relationships::RelationKind::Related,
crate::relationships::RelationKind::Duplicates,
] {
let model_supports = matches!(
kind,
crate::relationships::RelationKind::Blocks
| crate::relationships::RelationKind::BlockedBy
);
match map_relation_to_remote(tool, &rel(kind)) {
MappedRelation::Remote { field, value } => {
assert!(
!RELATION_WIRE_VERIFIED.contains(&tool),
"{tool}/{kind} mapped, so the wire must be verified"
);
assert!(!field.is_empty() && value.contains('#'));
}
MappedRelation::Unsupported(d) => {
assert_eq!(d.tool, tool, "the diagnostic names the tool");
assert_eq!(d.relation_id, "rel_TEST");
assert!(
!d.field.is_empty(),
"the diagnostic must name the specific field: {d}"
);
assert!(d.to_string().contains(&d.field), "and print it: {d}");
if model_supports {
assert!(
d.message.contains("not verified"),
"{tool}/{kind}: a model-supported kind must say the \
verb is unverified, not that the model lacks it: {d}"
);
} else {
assert!(
!d.message.contains("not verified"),
"{tool}/{kind}: a kind with no remote field must not \
blame an unverified verb: {d}"
);
}
}
}
}
}
}
#[test]
fn neither_direction_maps_without_a_verified_verb() {
for tool in [Tool::GitLab, Tool::Linear] {
let field = match tool {
Tool::GitLab => "blocking",
Tool::Linear => "blocks",
};
match map_relation_from_remote(tool, field, "blocks #2", "rel_TEST") {
MappedRelation::Remote { .. } => assert!(
!RELATION_WIRE_VERIFIED.contains(&tool),
"{tool}: the reverse direction mapped, so the wire must be verified"
),
MappedRelation::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert!(!d.relation_id.is_empty());
}
}
}
}
#[test]
fn an_unrecognised_remote_relation_field_never_becomes_a_local_edge() {
for tool in [Tool::GitLab, Tool::Linear] {
for (field, value) in [
("parent", "#2"),
("blocks", "not-a-number"),
("whatever", ""),
] {
match map_relation_from_remote(tool, field, value, "rel_X") {
MappedRelation::Remote { .. } => {
panic!("{tool}: `{field}={value}` must not become a local edge")
}
MappedRelation::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert_eq!(d.field, field, "the diagnostic names the field");
}
}
}
}
}
#[test]
fn both_trackers_share_one_mapping_seam() {
let source = include_str!("mod.rs");
let fns = [
"pub fn map_relation_to_remote",
"pub fn map_relation_from_remote",
];
for f in fns {
assert_eq!(
source.matches(&format!("{f}(")).count(),
1,
"{f} must be *defined* exactly once, in this module, for both \
trackers — a doc comment naming it is not a second implementation"
);
}
for (name, text) in [
("glab.rs", include_str!("glab.rs")),
("linear.rs", include_str!("linear.rs")),
] {
assert!(
!text.contains("fn map_relation_to_remote")
&& !text.contains("fn map_relation_from_remote"),
"{name} must not define its own mapping seam; the shared one is \\
keyed on `Tool` precisely so the two cannot diverge"
);
}
}
#[test]
fn a_sync_report_carries_both_the_count_and_the_diagnostics() {
let mut report = SyncReport::default();
let mut sink: Vec<u8> = Vec::new();
report
.note(
map_relation_to_remote(
Tool::GitLab,
&rel(crate::relationships::RelationKind::Related),
),
&mut sink,
)
.unwrap();
report
.note(
map_relation_to_remote(
Tool::GitLab,
&rel(crate::relationships::RelationKind::Blocks),
),
&mut sink,
)
.unwrap();
let expected = if RELATION_WIRE_VERIFIED.contains(&Tool::GitLab) {
1
} else {
2
};
assert_eq!(
report.diagnostics.len(),
expected,
"the report records every relation the tool cannot carry"
);
assert_eq!(report.handled, 0, "no issues were handled here");
let text = String::from_utf8(sink).unwrap();
assert!(
text.contains("cannot represent"),
"the stream says so: {text}"
);
}
fn split_completion_config() -> Config {
Config {
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".into(), "shipped".into(), "done".into()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::from([
("todo".to_string(), "unstarted".to_string()),
("shipped".to_string(), "completed".to_string()),
("done".to_string(), "active".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
}
}
#[test]
fn t032_a_remote_closed_state_maps_to_the_services_completion_column() {
let config = split_completion_config();
assert_eq!(
remote_to_local_status(Tool::GitLab, "closed", &config),
"shipped",
"a closed GitLab issue belongs in the column that is actually complete"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Done", &config),
"shipped"
);
}
#[test]
fn t032_export_closes_the_column_the_service_calls_complete() {
let config = split_completion_config();
assert_eq!(
local_to_remote_status(Tool::GitLab, "shipped", &config),
"closed"
);
assert_eq!(
local_to_remote_status(Tool::GitLab, "done", &config),
"opened"
);
assert_eq!(
local_to_remote_status(Tool::Linear, "shipped", &config),
"Done"
);
}
#[test]
fn t032_a_closed_remote_state_mapped_to_a_canceled_column_is_not_complete() {
let mut config = split_completion_config();
config
.kanban
.configurations
.get_mut("default")
.unwrap()
.category_map
.insert("done".to_string(), "canceled".to_string());
let board = config.default_config();
assert_eq!(
local_to_remote_status(Tool::GitLab, "shipped", &config),
"closed"
);
assert!(
!is_done_column(board, "done", &config),
"a canceled column is not locally complete"
);
}
#[test]
fn t032_the_existing_round_trips_are_unchanged() {
let config = Config::default();
assert_eq!(
local_to_remote_status(Tool::GitLab, "done", &config),
"closed"
);
assert_eq!(
local_to_remote_status(Tool::GitLab, "todo", &config),
"opened"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "closed", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Done", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "In Progress", &config),
"in progress",
"a name match wins over the first column"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "No Such State", &config),
"todo",
"and with no match at all, the first column"
);
}
#[test]
fn t032_the_sync_seam_holds_no_local_completion_definition() {
let source = include_str!("mod.rs");
let forbidden = ["fn remote", "status_is_done"].concat();
assert!(
!source.contains(&forbidden),
"the dead second definition is removed and must not come back"
);
let uses_service = source.matches("WorkflowCategory::is_complete").count();
assert!(
uses_service >= 2,
"both `is_done_column` and `completion_column` must ask the service, found \\
{uses_service} uses"
);
}
}