use std::collections::HashMap;
use std::collections::HashSet;
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 beads;
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 token_vars(self) -> &'static [&'static str] {
match self {
Tool::GitLab => &["GITLAB_TOKEN"],
Tool::Linear => &["LINEAR_TOKEN", "LINEAR_API_KEY"],
}
}
#[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<()>;
fn set_projects(&mut self, remote_ref: &str, projects: &[MappedProject]) -> Result<()>;
fn set_epics(&mut self, remote_ref: &str, epics: &[MappedEpic]) -> 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>,
pub projects: Vec<RemoteProject>,
pub epics: Vec<RemoteEpic>,
}
#[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(())
}
fn note_project(&mut self, mapped: MappedProject, out: &mut dyn Write) -> std::io::Result<()> {
if let MappedProject::Unsupported(diagnostic) = mapped {
writeln!(out, "{diagnostic}")?;
self.diagnostics.push(diagnostic);
}
Ok(())
}
fn note_epic(&mut self, mapped: MappedEpic, out: &mut dyn Write) -> std::io::Result<()> {
if let MappedEpic::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 local data ({}) — {}",
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] = &[Tool::Linear];
const RELATION_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
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,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteProject {
pub id: String,
pub name: Option<String>,
}
#[must_use]
pub fn remote_projects(value: Option<&serde_json::Value>) -> Vec<RemoteProject> {
fn id_of(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => String::new(),
}
}
fn one(value: &serde_json::Value) -> Option<RemoteProject> {
match value {
serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteProject {
id: id_of(value),
name: None,
}),
serde_json::Value::Object(map) => {
let id = ["id", "identifier", "key", "project_id"]
.iter()
.find_map(|key| map.get(*key))
.map(id_of)
.unwrap_or_default();
let name = ["name", "title", "display_name"]
.iter()
.find_map(|key| map.get(*key))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if id.trim().is_empty() && name.is_none() {
return None;
}
Some(RemoteProject { id, name })
}
_ => None,
}
}
let Some(value) = value else {
return Vec::new();
};
match value {
serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
other => one(other).into_iter().collect(),
}
}
const GITLAB_PROJECT_FIELD: &str = "projects";
const LINEAR_PROJECT_FIELD: &str = "project";
fn project_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_PROJECT_FIELD,
Tool::Linear => LINEAR_PROJECT_FIELD,
}
}
const PROJECT_WIRE_VERIFIED: &[Tool] = &[];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedProject {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
#[must_use]
pub fn map_project_to_remote(tool: Tool, issue_number: u32, project_id: &str) -> MappedProject {
let field = project_field(tool);
let id = project_id.trim();
if id.is_empty() {
return MappedProject::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{issue_number}->"),
field: field.to_string(),
tool,
message: format!(
"issue #{issue_number} carries a project association with no id, so there \
is nothing to send; the entry is kept locally — dissociating the empty \
reference, or editing the issue's project list, clears it"
),
});
}
if PROJECT_WIRE_VERIFIED.contains(&tool) {
return MappedProject::Remote {
field: field.to_string(),
value: id.to_string(),
};
}
MappedProject::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{issue_number}->{id}"),
field: field.to_string(),
tool,
message: format!(
"#{issue_number}'s membership of project `{id}` has no {} field to travel in \
— {} — so nothing is sent and the local association is kept",
tool.as_str(),
project_absence_reason(tool)
),
})
}
fn project_absence_reason(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => {
"`glab` names one owning project (`--project`) and exposes no membership field"
}
Tool::Linear => {
"Linear's issue carries one `project` and no list, so a second \
membership has nowhere to go"
}
}
}
#[must_use]
pub fn map_project_from_remote(tool: Tool, remote: &RemoteProject) -> MappedProject {
let id = remote.id.trim();
if id.is_empty() {
return MappedProject::Unsupported(RemoteMappingDiagnostic {
relation_id: remote.name.clone().unwrap_or_default(),
field: project_field(tool).to_string(),
tool,
message: format!(
"the remote reported a project it named `{}` but gave no id, and an id is \
the only thing a local association can match on; nothing was associated \
and the local list is unchanged",
remote.name.as_deref().unwrap_or("")
),
});
}
MappedProject::Remote {
field: crate::projects::PROJECTS_KEY.to_string(),
value: id.to_string(),
}
}
fn scrub_diagnostic(diagnostic: &mut RemoteMappingDiagnostic, secret: Option<&str>) {
if let Some(secret) = secret {
diagnostic.message = scrub_secret(&diagnostic.message, secret);
diagnostic.relation_id = scrub_secret(&diagnostic.relation_id, secret);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteEpic {
pub id: String,
pub name: Option<String>,
}
#[must_use]
pub fn remote_epics(value: Option<&serde_json::Value>) -> Vec<RemoteEpic> {
fn id_of(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => String::new(),
}
}
fn one(value: &serde_json::Value) -> Option<RemoteEpic> {
match value {
serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteEpic {
id: id_of(value),
name: None,
}),
serde_json::Value::Object(map) => {
let id = ["id", "identifier", "key", "epic_id"]
.iter()
.find_map(|key| map.get(*key))
.map(id_of)
.unwrap_or_default();
let name = ["name", "title", "display_name"]
.iter()
.find_map(|key| map.get(*key))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if id.trim().is_empty() && name.is_none() {
return None;
}
Some(RemoteEpic { id, name })
}
_ => None,
}
}
let Some(value) = value else {
return Vec::new();
};
match value {
serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
other => one(other).into_iter().collect(),
}
}
const EPIC_FIELD: &str = "epics";
const EPIC_WIRE_VERIFIED: &[Tool] = &[];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedEpic {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
#[must_use]
pub fn map_epic_to_remote(tool: Tool, issue_number: u32, epic_id: &str) -> MappedEpic {
let field = EPIC_FIELD;
let id = epic_id.trim();
if id.is_empty() {
return MappedEpic::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{issue_number}->"),
field: field.to_string(),
tool,
message: format!(
"issue #{issue_number} carries an epic reference with no id, so there is \
nothing to send; the entry is kept locally — dereferencing the empty \
reference, or editing the issue's epic list, clears it"
),
});
}
if EPIC_WIRE_VERIFIED.contains(&tool) {
return MappedEpic::Remote {
field: field.to_string(),
value: id.to_string(),
};
}
MappedEpic::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("{issue_number}->{id}"),
field: field.to_string(),
tool,
message: format!(
"#{issue_number}'s reference to epic `{id}` has no {} field to travel in \
— {} — so nothing is sent and the local reference is kept",
tool.as_str(),
epic_absence_reason(tool)
),
})
}
fn epic_absence_reason(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => {
"`glab` names at most one epic per issue (`epic_iid`) and exposes no flag \
to send one, so a membership list has nowhere to go"
}
Tool::Linear => {
"Linear has no epic level at all — the nearest concept is a project — so an \
epic reference has nowhere to go"
}
}
}
#[must_use]
pub fn map_epic_from_remote(tool: Tool, remote: &RemoteEpic) -> MappedEpic {
let id = remote.id.trim();
if id.is_empty() {
return MappedEpic::Unsupported(RemoteMappingDiagnostic {
relation_id: remote.name.clone().unwrap_or_default(),
field: EPIC_FIELD.to_string(),
tool,
message: format!(
"the remote reported an epic it named `{}` but gave no id, and an id is \
the only thing a local reference can match on; nothing was referenced \
and the local list is unchanged",
remote.name.as_deref().unwrap_or("")
),
});
}
MappedEpic::Remote {
field: crate::epics::EPICS_KEY.to_string(),
value: id.to_string(),
}
}
const GITLAB_HEALTH_FIELD: &str = "health_status";
const LINEAR_HEALTH_FIELD: &str = "health";
const HEALTH_MODEL_ABSENT: &[Tool] = &[Tool::Linear];
const HEALTH_WIRE_VERIFIED: &[Tool] = &[];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedHealth {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
#[must_use]
pub fn map_health_to_remote(
tool: Tool,
scope: &crate::health::HealthScope,
state: crate::health::HealthState,
) -> MappedHealth {
let field = health_field(tool);
if HEALTH_MODEL_ABSENT.contains(&tool) {
return MappedHealth::Unsupported(RemoteMappingDiagnostic {
relation_id: scope.as_str(),
field: field.to_string(),
tool,
message: format!(
"{}'s issue model has no health field, so `{}` stays local; a remote that \
cannot carry the value never clears it",
tool.as_str(),
state.as_str()
),
});
}
let crate::health::HealthScope::Issue(number) = scope else {
return MappedHealth::Unsupported(RemoteMappingDiagnostic {
relation_id: scope.as_str(),
field: field.to_string(),
tool,
message: format!(
"`{}` is a derived value for {}, which has no remote representation; only \
an issue's own health can be sent, so the local value is kept",
state.as_str(),
scope
),
});
};
if HEALTH_WIRE_VERIFIED.contains(&tool) {
return MappedHealth::Remote {
field: field.to_string(),
value: state.as_str().to_string(),
};
}
MappedHealth::Unsupported(RemoteMappingDiagnostic {
relation_id: format!("issue:{number}"),
field: field.to_string(),
tool,
message: format!(
"issue #{number}'s `{}` is expressible in {}'s model as `{field}`, but the CLI \
verb that would carry it is not verified, so nothing is sent; the local value \
is kept",
state.as_str(),
tool.as_str()
),
})
}
fn health_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_HEALTH_FIELD,
Tool::Linear => LINEAR_HEALTH_FIELD,
}
}
const INITIATIVE_FIELD: &str = "initiatives";
const INITIATIVE_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
const INITIATIVE_WIRE_VERIFIED: &[Tool] = &[];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedInitiative {
Remote { field: String, value: String },
Unsupported(RemoteMappingDiagnostic),
}
#[must_use]
pub fn map_initiative_to_remote(tool: Tool, initiative_id: &str) -> MappedInitiative {
let field = INITIATIVE_FIELD;
if INITIATIVE_WIRE_VERIFIED.contains(&tool) {
return MappedInitiative::Remote {
field: field.to_string(),
value: initiative_id.to_string(),
};
}
MappedInitiative::Unsupported(RemoteMappingDiagnostic {
relation_id: initiative_id.to_string(),
field: field.to_string(),
tool,
message: initiative_absence_reason(tool).to_string(),
})
}
fn initiative_absence_reason(tool: Tool) -> &'static str {
if INITIATIVE_CLI_ABSENT.contains(&tool) {
return "the CLI exposes no initiative command at all, so the whole local record is \
kept; a remote that cannot hold an initiative never clears one";
}
"the CLI's `initiatives` verbs take only a name, a description and a status — they \
cannot carry `owner`, `priority`, `labels`, `target_date`, or the typed \
project/epic/milestone associations — so sending those three fields would drop the \
rest; the whole local record is kept instead"
}
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,
target: Option<&str>,
) -> 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) {
let value = match tool {
Tool::Linear => match target {
Some(target) => target.to_string(),
None => {
return MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation.id.clone(),
field: field.to_string(),
tool,
message: format!(
"`{}` targets #{}, which has not been exported yet, so its \
remote identifier is unknown and the edge cannot be sent; \
the local edge is kept",
relation.kind.as_str(),
relation.to
),
});
}
},
Tool::GitLab => format!("{value} #{}", relation.to),
};
return MappedRelation::Remote {
field: field.to_string(),
value,
};
}
if model_supports {
let reason = if RELATION_CLI_ABSENT.contains(&tool) {
format!(
"the {} CLI exposes no issue-relation command, so it cannot be sent",
tool.as_str()
)
} else {
"the CLI verb that would carry it is not verified, so nothing is sent".to_string()
};
return MappedRelation::Unsupported(RemoteMappingDiagnostic {
relation_id: relation.id.clone(),
field: field.to_string(),
tool,
message: format!(
"`{}` is expressible in {}'s model as `{field}`, but {reason}; 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"),
}
}
const DRY_RUN_NEW_REF: &str = "<new remote id>";
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();
let export_set: HashSet<u32> = store.list()?.into_iter().map(|i| i.number).collect();
let existing: HashMap<u32, String> = store
.list()?
.into_iter()
.filter_map(|i| remote_ref(tool, &i).map(|r| (i.number, r.to_string())))
.collect();
for issue in store.list()? {
for rel in store.relationships_for(issue.number)? {
if rel.from != issue.number {
continue;
}
let other = if rel.from == issue.number {
rel.to
} else {
rel.from
};
let target = if let Some(r) = existing.get(&other) {
Some(r.as_str())
} else if export_set.contains(&other) {
Some(DRY_RUN_NEW_REF)
} else {
None
};
match map_relation_to_remote(tool, &rel, target) {
MappedRelation::Remote { field, value } => {
writeln!(out, "would export {tool} {field} {value} ({})", rel.id)?;
}
unsupported @ MappedRelation::Unsupported(_) => {
report.note(unsupported, out)?
}
}
}
if issue.parent.is_some() {
report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
}
for project_id in issue.projects.as_deref().unwrap_or_default() {
report.note_project(map_project_to_remote(tool, issue.number, project_id), out)?;
}
for epic_id in issue.epics.as_deref().unwrap_or_default() {
report.note_epic(map_epic_to_remote(tool, issue.number, epic_id), out)?;
}
}
return Ok(report);
}
let mut remote_ids: HashMap<u32, String> = HashMap::new();
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 mut sendable_projects: Vec<MappedProject> = Vec::new();
for project_id in issue.projects.as_deref().unwrap_or_default() {
match map_project_to_remote(tool, issue.number, project_id) {
MappedProject::Remote { field, value } => {
writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
sendable_projects.push(MappedProject::Remote { field, value });
}
unsupported @ MappedProject::Unsupported(_) => {
report.note_project(unsupported, out)?;
}
}
}
let mut sendable_epics: Vec<MappedEpic> = Vec::new();
for epic_id in issue.epics.as_deref().unwrap_or_default() {
match map_epic_to_remote(tool, issue.number, epic_id) {
MappedEpic::Remote { field, value } => {
writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
sendable_epics.push(MappedEpic::Remote { field, value });
}
unsupported @ MappedEpic::Unsupported(_) => {
report.note_epic(unsupported, 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() {
adapter.set_projects(&remote, &sendable_projects)?;
adapter.set_epics(&remote, &sendable_epics)?;
remote_ids.insert(issue.number, remote);
}
handled += 1;
}
for issue in store.list()? {
let Some(remote) = remote_ids.get(&issue.number).cloned() else {
continue;
};
let mut sendable = Vec::new();
for rel in store.relationships_for(issue.number)? {
if rel.from != issue.number {
continue;
}
let other = if rel.from == issue.number {
rel.to
} else {
rel.from
};
let target = remote_ids.get(&other).map(String::as_str);
match map_relation_to_remote(tool, &rel, target) {
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)?;
}
report.handled = handled;
writeln!(
out,
"exported {handled} issue(s) to {tool} ({} diagnostic(s) — local data the remote \
cannot hold)",
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();
let mut project_payloads: Vec<(String, Vec<RemoteProject>)> = Vec::new();
let mut epic_payloads: Vec<(String, Vec<RemoteEpic>)> = Vec::new();
let mut located: Vec<(String, u32)> = Vec::new();
for remote in remote_issues.clone() {
if let Some(parent) = remote.parent.clone() {
claims.push((remote.id.clone(), parent));
}
if !remote.projects.is_empty() {
project_payloads.push((remote.id.clone(), remote.projects.clone()));
}
if !remote.epics.is_empty() {
epic_payloads.push((remote.id.clone(), remote.epics.clone()));
}
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 {
projects: None,
id: None,
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![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: crate::model::today(),
type_: None,
updated: crate::model::today(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: 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.transaction(|| store.create(&mut issue, &remote.description, None))?;
imported.push((remote.id.clone(), issue.number));
located.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) => {
located.push((remote.id.clone(), local.number));
if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
let backup = crate::backup::save(&store.issues_dir, &local.filename);
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.transaction(|| 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.transaction(|| store.update(&filename, &overwritten))?;
let backup_label = backup
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "none".to_string());
writeln!(
out,
"overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup_label})",
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)?;
apply_imported_projects(
store,
config,
tool,
&project_payloads,
&located,
&mut report,
out,
)?;
apply_imported_epics(
store,
config,
tool,
&epic_payloads,
&located,
&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 apply_imported_projects(
store: &Store,
config: &Config,
tool: Tool,
payloads: &[(String, Vec<RemoteProject>)],
located: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
if payloads.is_empty() {
return Ok(());
}
let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
.secret()
.map(str::to_string);
for (remote_id, projects) in payloads {
let Some(local_number) = located
.iter()
.find(|(id, _)| id == remote_id)
.map(|(_, number)| *number)
else {
continue;
};
for project in projects {
let project_id = match map_project_from_remote(tool, project) {
MappedProject::Remote { value, .. } => value,
MappedProject::Unsupported(mut diagnostic) => {
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
continue;
}
};
if !config.projects.contains_key(project_id.as_str()) {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{project_id}"),
field: project_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{local_number} in project `{project_id}`{}, \
which this repository's registry does not declare; nothing was \
written — the association stays on the remote and no missing \
reference was created locally",
project
.name
.as_deref()
.map(|name| format!(" (`{name}`)"))
.unwrap_or_default()
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
continue;
}
match store.associate(&local_number.to_string(), &project_id) {
Ok(applied) if applied.changed => {
writeln!(
out,
"associated {tool} issue #{local_number} with {}",
crate::projects::association_detail(&project_id, true)
)?;
}
Ok(_) => {}
Err(error) => {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{project_id}"),
field: project_field(tool).to_string(),
tool,
message: format!(
"the remote reports #{local_number} in project `{project_id}`, \
but the project service refused the association: {error}; the \
local association list is unchanged"
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_project(MappedProject::Unsupported(diagnostic), out)?;
}
}
}
}
Ok(())
}
fn apply_imported_epics(
store: &Store,
config: &Config,
tool: Tool,
payloads: &[(String, Vec<RemoteEpic>)],
located: &[(String, u32)],
report: &mut SyncReport,
out: &mut dyn Write,
) -> Result<()> {
if payloads.is_empty() {
return Ok(());
}
let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
.secret()
.map(str::to_string);
for (remote_id, epics) in payloads {
let Some(local_number) = located
.iter()
.find(|(id, _)| id == remote_id)
.map(|(_, number)| *number)
else {
continue;
};
for epic in epics {
let epic_id = match map_epic_from_remote(tool, epic) {
MappedEpic::Remote { value, .. } => value,
MappedEpic::Unsupported(mut diagnostic) => {
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
continue;
}
};
if !config.epics.contains_key(epic_id.as_str()) {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{epic_id}"),
field: EPIC_FIELD.to_string(),
tool,
message: format!(
"the remote reports #{local_number} in epic `{epic_id}`{}, which \
this repository's registry does not declare; nothing was written \
— the reference stays on the remote and no missing reference \
was created locally",
epic.name
.as_deref()
.map(|name| format!(" (`{name}`)"))
.unwrap_or_default()
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
continue;
}
match store.reference_epic(&local_number.to_string(), &epic_id) {
Ok(applied) if applied.changed => {
writeln!(
out,
"referenced {tool} issue #{local_number} with {}",
crate::epics::reference_detail(&epic_id, true)
)?;
}
Ok(_) => {}
Err(error) => {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: format!("{local_number}->{epic_id}"),
field: EPIC_FIELD.to_string(),
tool,
message: format!(
"the remote reports #{local_number} in epic `{epic_id}`, but \
the epic service refused the reference: {error}; the local \
reference list is unchanged"
),
};
scrub_diagnostic(&mut diagnostic, secret.as_deref());
report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
}
}
}
}
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_maps_only_where_the_wire_is_verified_and_otherwise_diagnoses() {
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), Some("ENG-42")) {
MappedRelation::Remote { field, value } => {
assert!(
model_supports && RELATION_WIRE_VERIFIED.contains(&tool),
"{tool}/{kind} mapped, so the tool must model the kind and \
be on the verified wire"
);
assert_ne!(field, "");
if tool == Tool::Linear {
assert_eq!(
value, "ENG-42",
"Linear sends the target's remote identifier"
);
} else {
assert!(value.contains('#'));
}
}
MappedRelation::Unsupported(d) => {
assert!(
!RELATION_WIRE_VERIFIED.contains(&tool) || !model_supports,
"{tool}/{kind} is on the verified wire and modelled, so it \
must map, not diagnose"
);
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!(
RELATION_CLI_ABSENT.contains(&tool)
&& d.message.contains("exposes no issue-relation command")
&& !d.message.contains("not verified"),
"{tool}/{kind}: a model-supported kind on an unverified \
tool must name the real reason; a CLI verified to have \
no relation command must say so, not 'not verified': {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 a_relation_without_a_remote_target_is_reported_not_guessed() {
let MappedRelation::Unsupported(d) = map_relation_to_remote(
Tool::Linear,
&rel(crate::relationships::RelationKind::Blocks),
None,
) else {
panic!("a missing target must not map");
};
assert_eq!(d.tool, Tool::Linear);
assert_eq!(d.field, "blocks");
assert!(
d.message.contains("#2") && d.message.contains("not been exported"),
"the diagnostic names the target and why: {d}"
);
}
#[test]
fn the_reverse_direction_maps_exactly_when_the_wire_is_verified() {
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!(
!RELATION_WIRE_VERIFIED.contains(&tool),
"{tool}: the wire is verified, so the reverse direction must map"
);
assert_eq!(d.tool, tool);
assert_ne!(d.relation_id, "");
}
}
}
}
#[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),
None,
),
&mut sink,
)
.unwrap();
report
.note(
map_relation_to_remote(
Tool::GitLab,
&rel(crate::relationships::RelationKind::Blocks),
None,
),
&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"
);
}
#[test]
fn t035_a_project_association_maps_back_to_its_local_id() {
for tool in [Tool::GitLab, Tool::Linear] {
let remote = RemoteProject {
id: "p-1".to_string(),
name: Some("One".to_string()),
};
match map_project_from_remote(tool, &remote) {
MappedProject::Remote { field, value } => {
assert_eq!(field, crate::projects::PROJECTS_KEY);
assert_eq!(value, "p-1", "the id, never the remote's name");
}
MappedProject::Unsupported(d) => {
panic!("{tool}: a reported id must map back: {d}")
}
}
}
}
#[test]
fn t035_every_project_association_diagnoses_with_its_field_its_tool_and_its_reason() {
for tool in [Tool::GitLab, Tool::Linear] {
match map_project_to_remote(tool, 3, "p-7") {
MappedProject::Remote { field, value } => {
assert!(
PROJECT_WIRE_VERIFIED.contains(&tool),
"{tool}: an association mapped without a verified membership field"
);
assert_eq!(field, project_field(tool));
assert_eq!(value, "p-7");
}
MappedProject::Unsupported(d) => {
assert_eq!(d.tool, tool, "the diagnostic names the tool");
assert_eq!(d.field, project_field(tool), "and the field");
assert_eq!(
d.relation_id, "3->p-7",
"and the issue and the association it is about"
);
let text = d.to_string();
for needle in [d.field.as_str(), "p-7", "#3"] {
assert!(text.contains(needle), "`{needle}` missing from: {text}");
}
assert!(
!d.message.contains("not verified"),
"{tool}: there is no membership verb to verify: {text}"
);
}
}
}
assert!(project_absence_reason(Tool::GitLab).contains("--project"));
assert!(project_absence_reason(Tool::Linear).contains("one `project`"));
assert_ne!(
project_absence_reason(Tool::GitLab),
project_absence_reason(Tool::Linear)
);
}
#[test]
fn t035_an_association_with_no_id_is_diagnosed_and_kept() {
for tool in [Tool::GitLab, Tool::Linear] {
match map_project_to_remote(tool, 4, " ") {
MappedProject::Remote { .. } => panic!("{tool}: nothing to send"),
MappedProject::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert_eq!(d.field, project_field(tool));
assert!(d.message.contains("no id"), "{}", d.message);
assert!(
d.message.contains("kept locally"),
"and says the entry survives: {}",
d.message
);
}
}
}
}
#[test]
fn t035_remote_project_reading_keeps_order_and_accepts_every_known_shape() {
let list: serde_json::Value =
serde_json::from_str(r#"["p-1", {"id": "p-2", "name": "Two"}, 3]"#).unwrap();
assert_eq!(
remote_projects(Some(&list)),
vec![
RemoteProject {
id: "p-1".into(),
name: None
},
RemoteProject {
id: "p-2".into(),
name: Some("Two".into())
},
RemoteProject {
id: "3".into(),
name: None
},
],
"the payload's order is the list's order"
);
for raw in [
r#"{"identifier": "p-9"}"#,
r#"{"project_id": "p-9"}"#,
r#""p-9""#,
] {
let value: serde_json::Value = serde_json::from_str(raw).unwrap();
assert_eq!(
remote_projects(Some(&value)),
vec![RemoteProject {
id: "p-9".into(),
name: None
}],
"{raw}"
);
}
let named: serde_json::Value =
serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
assert_eq!(
remote_projects(Some(&named)),
vec![RemoteProject {
id: String::new(),
name: Some("Unidentified".into())
}]
);
assert_eq!(remote_projects(None), [] as [crate::sync::RemoteProject; 0]);
assert_eq!(
remote_projects(Some(&serde_json::Value::Null)),
[] as [crate::sync::RemoteProject; 0]
);
assert_eq!(
remote_projects(Some(&serde_json::json!([true, false]))),
[] as [crate::sync::RemoteProject; 0]
);
}
#[test]
fn t035_a_remote_project_with_no_id_is_diagnosed_never_guessed_at() {
for tool in [Tool::GitLab, Tool::Linear] {
let remote = RemoteProject {
id: " ".to_string(),
name: Some("Named but unidentified".to_string()),
};
match map_project_from_remote(tool, &remote) {
MappedProject::Remote { .. } => {
panic!("{tool}: an absent id cannot become a local association")
}
MappedProject::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert_eq!(d.field, project_field(tool));
assert!(d.message.contains("Named but unidentified"));
assert!(d.message.contains("no id"));
}
}
}
}
#[test]
fn t036_a_project_diagnostic_built_from_remote_output_is_scrubbed() {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: "1->lin-secret".to_string(),
field: LINEAR_PROJECT_FIELD.to_string(),
tool: Tool::Linear,
message: "the remote reports #1 in project `p-1` (`lin-secret`)".to_string(),
};
scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
assert!(
diagnostic.message.contains("<token>"),
"{}",
diagnostic.message
);
assert!(!diagnostic.message.contains("lin-secret"));
assert!(!diagnostic.relation_id.contains("lin-secret"));
let mut untouched = RemoteMappingDiagnostic {
relation_id: "1->p-1".to_string(),
field: LINEAR_PROJECT_FIELD.to_string(),
tool: Tool::Linear,
message: "no secret here".to_string(),
};
let expected = untouched.clone();
scrub_diagnostic(&mut untouched, None);
assert_eq!(untouched, expected);
}
#[test]
fn t036_no_tracker_sends_a_membership_and_both_adapters_share_the_seam() {
for tool in [Tool::GitLab, Tool::Linear] {
assert!(!PROJECT_WIRE_VERIFIED.contains(&tool));
assert!(matches!(
map_project_to_remote(tool, 1, "p-1"),
MappedProject::Unsupported(_)
));
}
let source = include_str!("mod.rs");
for f in [
"pub fn map_project_to_remote",
"pub fn map_project_from_remote",
] {
assert_eq!(
source.matches(&format!("{f}(")).count(),
1,
"{f} must be *defined* exactly once, in this module, for both trackers"
);
}
for (name, text) in [
("glab.rs", include_str!("glab.rs")),
("linear.rs", include_str!("linear.rs")),
] {
assert!(
!text.contains("fn map_project_to_remote")
&& !text.contains("fn map_project_from_remote"),
"{name} must not define its own mapping seam"
);
assert!(
text.contains("fn set_projects"),
"{name} must implement the shared membership seam"
);
}
}
#[test]
fn t037_a_report_records_a_project_diagnostic_and_prints_the_field() {
let mut report = SyncReport::default();
let mut sink: Vec<u8> = Vec::new();
report
.note_project(map_project_to_remote(Tool::GitLab, 2, "p-1"), &mut sink)
.unwrap();
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.handled, 0);
let text = String::from_utf8(sink).unwrap();
assert!(text.contains("gitlab: projects"), "{text}");
assert!(text.contains("p-1"), "{text}");
assert_eq!(report.diagnostics[0].field, GITLAB_PROJECT_FIELD);
}
#[test]
fn t037_an_epic_reference_maps_back_to_its_local_id() {
for tool in [Tool::GitLab, Tool::Linear] {
let remote = RemoteEpic {
id: "e-1".to_string(),
name: Some("One".to_string()),
};
match map_epic_from_remote(tool, &remote) {
MappedEpic::Remote { field, value } => {
assert_eq!(field, crate::epics::EPICS_KEY);
assert_eq!(value, "e-1", "the id, never the remote's name");
}
MappedEpic::Unsupported(d) => {
panic!("{tool}: a reported id must map back: {d}")
}
}
}
}
#[test]
fn t037_every_epic_reference_diagnoses_with_its_field_its_tool_and_its_reason() {
for tool in [Tool::GitLab, Tool::Linear] {
match map_epic_to_remote(tool, 3, "e-7") {
MappedEpic::Remote { field, value } => {
assert!(
EPIC_WIRE_VERIFIED.contains(&tool),
"{tool}: a reference mapped without a verified epic field"
);
assert_eq!(field, EPIC_FIELD);
assert_eq!(value, "e-7");
}
MappedEpic::Unsupported(d) => {
assert_eq!(d.tool, tool, "the diagnostic names the tool");
assert_eq!(d.field, EPIC_FIELD, "and the field");
assert_eq!(
d.relation_id, "3->e-7",
"and the issue and the reference it is about"
);
let text = d.to_string();
for needle in [d.field.as_str(), "e-7", "#3"] {
assert!(text.contains(needle), "`{needle}` missing from: {text}");
}
assert!(
!d.message.contains("not verified"),
"{tool}: there is no epic verb to verify: {text}"
);
}
}
}
assert!(epic_absence_reason(Tool::GitLab).contains("epic_iid"));
assert!(epic_absence_reason(Tool::Linear).contains("project"));
assert_ne!(
epic_absence_reason(Tool::GitLab),
epic_absence_reason(Tool::Linear)
);
}
#[test]
fn t037_an_epic_reference_with_no_id_is_diagnosed_and_kept() {
for tool in [Tool::GitLab, Tool::Linear] {
match map_epic_to_remote(tool, 4, " ") {
MappedEpic::Remote { .. } => panic!("{tool}: nothing to send"),
MappedEpic::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert_eq!(d.field, EPIC_FIELD);
assert!(d.message.contains("no id"), "{}", d.message);
assert!(
d.message.contains("kept locally"),
"and says the entry survives: {}",
d.message
);
}
}
}
}
#[test]
fn t037_remote_epic_reading_keeps_order_and_accepts_every_known_shape() {
let list: serde_json::Value =
serde_json::from_str(r#"["e-1", {"id": "e-2", "name": "Two"}, 3]"#).unwrap();
assert_eq!(
remote_epics(Some(&list)),
vec![
RemoteEpic {
id: "e-1".into(),
name: None
},
RemoteEpic {
id: "e-2".into(),
name: Some("Two".into())
},
RemoteEpic {
id: "3".into(),
name: None
},
],
"the payload's order is the list's order"
);
for raw in [
r#"{"identifier": "e-9"}"#,
r#"{"epic_id": "e-9"}"#,
r#""e-9""#,
] {
let value: serde_json::Value = serde_json::from_str(raw).unwrap();
assert_eq!(
remote_epics(Some(&value)),
vec![RemoteEpic {
id: "e-9".into(),
name: None
}],
"{raw}"
);
}
let named: serde_json::Value =
serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
assert_eq!(
remote_epics(Some(&named)),
vec![RemoteEpic {
id: String::new(),
name: Some("Unidentified".into())
}]
);
assert_eq!(remote_epics(None), [] as [crate::sync::RemoteEpic; 0]);
assert_eq!(
remote_epics(Some(&serde_json::Value::Null)),
[] as [crate::sync::RemoteEpic; 0]
);
assert_eq!(
remote_epics(Some(&serde_json::json!([true, false]))),
[] as [crate::sync::RemoteEpic; 0]
);
}
#[test]
fn t037_a_remote_epic_with_no_id_is_diagnosed_never_guessed_at() {
for tool in [Tool::GitLab, Tool::Linear] {
let remote = RemoteEpic {
id: " ".to_string(),
name: Some("Named but unidentified".to_string()),
};
match map_epic_from_remote(tool, &remote) {
MappedEpic::Remote { .. } => {
panic!("{tool}: an absent id cannot become a local reference")
}
MappedEpic::Unsupported(d) => {
assert_eq!(d.tool, tool);
assert_eq!(d.field, EPIC_FIELD);
assert!(d.message.contains("Named but unidentified"));
assert!(d.message.contains("no id"));
}
}
}
}
#[test]
fn t038_a_remote_epic_diagnostic_is_scrubbed() {
let mut diagnostic = RemoteMappingDiagnostic {
relation_id: "1->lin-secret".to_string(),
field: EPIC_FIELD.to_string(),
tool: Tool::Linear,
message: "the remote reports #1 in epic `e-1` (`lin-secret`)".to_string(),
};
scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
assert!(
diagnostic.message.contains("<token>"),
"{}",
diagnostic.message
);
assert!(!diagnostic.message.contains("lin-secret"));
assert!(!diagnostic.relation_id.contains("lin-secret"));
}
#[test]
fn t038_no_tracker_sends_an_epic_reference_and_both_adapters_share_the_seam() {
for tool in [Tool::GitLab, Tool::Linear] {
assert!(!EPIC_WIRE_VERIFIED.contains(&tool));
assert!(matches!(
map_epic_to_remote(tool, 1, "e-1"),
MappedEpic::Unsupported(_)
));
}
let source = include_str!("mod.rs");
for f in ["pub fn map_epic_to_remote", "pub fn map_epic_from_remote"] {
assert_eq!(
source.matches(&format!("{f}(")).count(),
1,
"{f} must be *defined* exactly once, in this module, for both trackers"
);
}
for (name, text) in [
("glab.rs", include_str!("glab.rs")),
("linear.rs", include_str!("linear.rs")),
] {
assert!(
!text.contains("fn map_epic_to_remote")
&& !text.contains("fn map_epic_from_remote"),
"{name} must not define its own mapping seam"
);
assert!(
text.contains("fn set_epics"),
"{name} must implement the shared epic seam"
);
}
}
#[test]
fn t038_a_report_records_an_epic_diagnostic_and_prints_the_field() {
let mut report = SyncReport::default();
let mut sink: Vec<u8> = Vec::new();
report
.note_epic(map_epic_to_remote(Tool::GitLab, 2, "e-1"), &mut sink)
.unwrap();
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.handled, 0);
let text = String::from_utf8(sink).unwrap();
assert!(text.contains("gitlab: epics"), "{text}");
assert!(text.contains("e-1"), "{text}");
assert_eq!(report.diagnostics[0].field, EPIC_FIELD);
}
}