#[allow(clippy::wildcard_imports)]
use super::*;
#[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 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
}
}
#[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,
report_diagnostics: bool,
}
impl SyncReport {
pub(crate) fn diagnostics_in_report(mut self) -> Self {
self.report_diagnostics = true;
self
}
pub(super) fn note(
&mut self,
mapped: MappedRelation,
out: &mut dyn Write,
) -> std::io::Result<()> {
if let MappedRelation::Unsupported(diagnostic) = mapped {
if self.report_diagnostics {
writeln!(out, "{diagnostic}")?;
}
self.diagnostics.push(diagnostic);
}
Ok(())
}
pub(super) fn note_parent(
&mut self,
mapped: MappedParent,
out: &mut dyn Write,
) -> std::io::Result<()> {
if let MappedParent::Unsupported(diagnostic) = mapped {
if self.report_diagnostics {
writeln!(out, "{diagnostic}")?;
}
self.diagnostics.push(diagnostic);
}
Ok(())
}
pub(super) fn note_project(
&mut self,
mapped: MappedProject,
out: &mut dyn Write,
) -> std::io::Result<()> {
if let MappedProject::Unsupported(diagnostic) = mapped {
if self.report_diagnostics {
writeln!(out, "{diagnostic}")?;
}
self.diagnostics.push(diagnostic);
}
Ok(())
}
pub(super) fn note_epic(
&mut self,
mapped: MappedEpic,
out: &mut dyn Write,
) -> std::io::Result<()> {
if let MappedEpic::Unsupported(diagnostic) = mapped {
if self.report_diagnostics {
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),
}
#[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()
),
})
}
pub(super) fn parent_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_PARENT_FIELD,
Tool::Linear => LINEAR_PARENT_FIELD,
}
}
#[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(),
}
}
pub(super) fn project_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_PROJECT_FIELD,
Tool::Linear => LINEAR_PROJECT_FIELD,
}
}
#[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)
),
})
}
pub(super) 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(),
}
}
pub(super) 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);
}
}
#[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(),
}
}
#[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)
),
})
}
pub(super) 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(),
}
}
#[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()
),
})
}
pub(super) fn health_field(tool: Tool) -> &'static str {
match tool {
Tool::GitLab => GITLAB_HEALTH_FIELD,
Tool::Linear => LINEAR_HEALTH_FIELD,
}
}
#[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(),
})
}
pub(super) 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"
}
#[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())
})
}
}
}
}
pub(super) 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)
}
pub(super) fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
crate::workflow::completion_column_or_default(board, config)
}
pub(super) 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)
}
pub(super) 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>")
}
pub(super) 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(super) 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,
)))
}
}
}