use serde::Deserialize;
use crate::error::{Result, TixError};
use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
pub struct LinearAdapter {
runner: Box<dyn CommandRunner>,
token: Option<String>,
team: String,
}
impl LinearAdapter {
pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
LinearAdapter {
runner: Box::new(runner),
token,
team,
}
}
fn child_env(&self) -> Vec<(String, String)> {
match &self.token {
Some(token) => vec![("LINEAR_API_KEY".to_string(), token.clone())],
None => Vec::new(),
}
}
fn run(&self, args: &[String]) -> Result<CommandOutcome> {
self.runner
.run(Tool::Linear.binary(), args, &self.child_env())
}
fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
let mut message = format!("{context}: {}", outcome.text().trim());
if let Some(secret) = &self.token {
message = scrub_secret(&message, secret);
}
TixError::External(message)
}
fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
serde_json::from_str::<serde_json::Value>(&outcome.stdout)
.ok()
.and_then(|value| {
value
.get("identifier")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.ok_or_else(|| {
TixError::External(format!(
"`linear-cli issues create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
))
})
}
fn fetch_issue(&self, identifier: &str) -> Result<RemoteIssue> {
let args: Vec<String> = vec![
"issues".into(),
"get".into(),
"-o".into(),
"json".into(),
identifier.to_string(),
];
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issues get` failed", &outcome));
}
let detail: LinearDetailIssue = serde_json::from_str(&outcome.stdout).map_err(|e| {
TixError::External(format!(
"`linear-cli issues get` did not return a JSON object: {e}\n{}",
scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
))
})?;
Ok(RemoteIssue::from(detail))
}
}
impl TrackerAdapter for LinearAdapter {
fn tool(&self) -> Tool {
Tool::Linear
}
fn create_issue(
&mut self,
title: &str,
body: &str,
labels: &[String],
_assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<String> {
let mut args: Vec<String> = vec![
"issues".into(),
"create".into(),
format!("-t={}", self.team),
format!("-d={body}"),
];
for label in labels {
args.push(format!("-l={label}"));
}
if let Some(p) = priority.and_then(linear_priority) {
args.push(format!("-p={p}"));
}
args.push(format!("-s={status}"));
args.push("-o".into());
args.push("json".into());
args.push("--".into());
args.push(title.to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issues create` failed", &outcome));
}
self.parse_identifier(&outcome)
}
fn update_issue(
&mut self,
remote_ref: &str,
title: &str,
body: &str,
labels: &[String],
_assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<()> {
let mut args: Vec<String> = vec![
"issues".into(),
"update".into(),
format!("-T={title}"),
format!("-d={body}"),
];
for label in labels {
args.push(format!("-l={label}"));
}
if let Some(p) = priority.and_then(linear_priority) {
args.push(format!("-p={p}"));
}
args.push(format!("-s={status}"));
args.push("--".into());
args.push(remote_ref.to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issues update` failed", &outcome));
}
Ok(())
}
fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
Ok(())
}
fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
let args: Vec<String> = vec![
"issues".into(),
"list".into(),
format!("-t={}", self.team),
"-o".into(),
"json".into(),
];
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issues list` failed", &outcome));
}
let listed: Vec<LinearListIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
TixError::External(format!(
"`linear-cli issues list` did not return a JSON array: {e}\n{}",
scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
))
})?;
let mut issues = Vec::new();
for item in listed {
if item.state_name() == "Done" {
continue;
}
issues.push(self.fetch_issue(&item.identifier)?);
}
Ok(issues)
}
fn done_via_status(&self) -> bool {
true
}
fn set_relations(
&mut self,
remote_ref: &str,
relations: &[super::MappedRelation],
) -> Result<()> {
for relation in relations {
let super::MappedRelation::Remote { field, value } = relation else {
continue;
};
let args: Vec<String> = vec![
"relations".into(),
"add".into(),
"-r".into(),
field.clone(),
remote_ref.to_string(),
value.clone(),
];
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli relations add` failed", &outcome));
}
}
Ok(())
}
fn set_projects(&mut self, _remote_ref: &str, projects: &[super::MappedProject]) -> Result<()> {
debug_assert!(
projects.is_empty(),
"Linear's issue carries one `project`, so no membership list can be \
sent: flip `PROJECT_WIRE_VERIFIED` only together with an \
implementation here"
);
Ok(())
}
fn set_epics(&mut self, _remote_ref: &str, epics: &[super::MappedEpic]) -> Result<()> {
debug_assert!(
epics.is_empty(),
"Linear has no epic level, so no epic reference list can be sent: flip \
`EPIC_WIRE_VERIFIED` only together with an implementation here"
);
Ok(())
}
}
#[must_use]
fn linear_priority(priority: &str) -> Option<&'static str> {
match priority {
"low" => Some("4"),
"medium" => Some("3"),
"high" => Some("2"),
"" => Some("0"),
_ => None,
}
}
#[derive(Debug, Deserialize)]
struct LinearListIssue {
identifier: String,
#[serde(default, alias = "state")]
state: Option<LinearStatus>,
}
impl LinearListIssue {
fn state_name(&self) -> &str {
self.state.as_ref().map(LinearStatus::name).unwrap_or("")
}
}
#[derive(Debug, Deserialize)]
struct LinearDetailIssue {
identifier: String,
#[serde(default)]
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default, alias = "state")]
state: Option<LinearStatus>,
#[serde(default, alias = "updatedAt")]
updated_at: Option<String>,
#[serde(default)]
assignee: Option<LinearAssignee>,
#[serde(default)]
labels: Option<LinearLabels>,
#[serde(default)]
parent: Option<serde_json::Value>,
#[serde(default, alias = "projects")]
project: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct LinearAssignee {
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LinearLabels {
#[serde(default)]
nodes: Vec<LinearLabelNode>,
}
#[derive(Debug, Deserialize)]
struct LinearLabelNode {
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LinearStatus {
Name(String),
Named { name: String },
}
impl LinearStatus {
fn name(&self) -> &str {
match self {
LinearStatus::Name(name) | LinearStatus::Named { name } => name,
}
}
}
impl From<LinearDetailIssue> for RemoteIssue {
fn from(issue: LinearDetailIssue) -> Self {
RemoteIssue {
id: issue.identifier,
title: issue.title,
description: issue.description.unwrap_or_default(),
labels: issue
.labels
.map(|labels| labels.nodes.into_iter().map(|n| n.name).collect())
.unwrap_or_default(),
state: issue
.state
.as_ref()
.map(LinearStatus::name)
.unwrap_or("")
.to_string(),
updated_at: issue.updated_at,
assignee: issue.assignee.and_then(|a| a.name),
parent: linear_parent_id(issue.parent.as_ref()),
projects: crate::sync::remote_projects(issue.project.as_ref()),
epics: Vec::new(),
}
}
}
fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
fn one(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(map) => map
.get("id")
.or_else(|| map.get("identifier"))
.and_then(|v| v.as_str())
.map(str::to_string),
_ => None,
}
}
match value? {
serde_json::Value::Array(items) => items.first().and_then(one),
other => one(other),
}
}
impl CommandOutcome {
#[must_use]
pub fn linear_created(identifier: &str) -> CommandOutcome {
CommandOutcome {
success: true,
status: Some(0),
stdout: format!(
"{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
),
stderr: String::new(),
}
}
#[must_use]
pub fn linear_list(payload: &str) -> CommandOutcome {
CommandOutcome {
success: true,
status: Some(0),
stdout: payload.to_string(),
stderr: String::new(),
}
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::sync::MappedRelation;
use crate::sync::glab::{FakeRunner, RecordedCall};
fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
LinearAdapter::new(runner, token.map(String::from), "Eng".into())
}
fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
adapter
.runner
.as_any()
.downcast_ref::<FakeRunner>()
.expect("adapter must hold a FakeRunner")
.take_calls()
}
#[test]
fn create_attaches_values_and_puts_the_positional_title_behind_double_dash() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
let mut adapter = adapter_with(runner, Some("lin-token"));
let id = adapter
.create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
.unwrap();
assert_eq!(id, "TIX-7");
let (argv, env) = &recorded(&adapter)[0];
assert_eq!(
argv.join(" "),
"linear-cli issues create -t=Eng -d=body -l=bug -p=2 -s=Done -o json -- Fix"
);
assert_eq!(
env,
&[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
);
}
#[test]
fn create_survives_a_hyphen_leading_body_and_title() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
let mut adapter = adapter_with(runner, None);
adapter
.create_issue(
"-leading title",
"- bullet one\n- bullet two",
&["bug".into()],
None,
None,
"Todo",
)
.unwrap();
let argv = &recorded(&adapter)[0].0;
assert_eq!(
argv.join(" "),
"linear-cli issues create -t=Eng -d=- bullet one\n- bullet two -l=bug -s=Todo -o json -- -leading title"
);
assert_eq!(
argv.last().unwrap(),
"-leading title",
"the title is the positional"
);
}
#[test]
fn update_survives_a_hyphen_leading_title_and_body() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None);
adapter
.update_issue("TIX-7", "-tricky title", "- body", &[], None, None, "Done")
.unwrap();
let argv = &recorded(&adapter)[0].0;
assert_eq!(
argv.join(" "),
"linear-cli issues update -T=-tricky title -d=- body -s=Done -- TIX-7"
);
}
#[test]
fn the_token_is_injected_under_the_name_the_tool_reads() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-1")]);
let mut adapter = adapter_with(runner, Some("lin-token"));
adapter
.create_issue("T", "b", &[], None, None, "Todo")
.unwrap();
assert_eq!(
&recorded(&adapter)[0].1,
&[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
);
}
#[test]
fn create_without_labels_omits_label_flag() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
let mut adapter = adapter_with(runner, None);
adapter
.create_issue("T", "b", &[], None, None, "todo")
.unwrap();
let joined = recorded(&adapter)[0].0.join(" ");
assert!(!joined.contains("-l="), "{joined}");
}
#[test]
fn priority_maps_to_linear_numbers_and_unknown_is_omitted() {
assert_eq!(linear_priority("low"), Some("4"));
assert_eq!(linear_priority("medium"), Some("3"));
assert_eq!(linear_priority("high"), Some("2"));
assert_eq!(linear_priority(""), Some("0"), "the empty string is `none`");
for unknown in ["urgent", "none", "MEDIUM", "custom-word"] {
assert_eq!(
linear_priority(unknown),
None,
"{unknown} must not be coerced to a number"
);
}
}
#[test]
fn an_unmappable_priority_omits_the_priority_flag() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-9")]);
let mut adapter = adapter_with(runner, None);
adapter
.create_issue("T", "b", &[], None, Some("urgent"), "Todo")
.unwrap();
let joined = recorded(&adapter)[0].0.join(" ");
assert!(
!joined.contains("-p="),
"unknown priority must be omitted: {joined}"
);
}
#[test]
fn update_passes_identifier_title_body_labels_priority_and_state() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None);
adapter
.update_issue(
"TIX-7",
"New title",
"new body",
&["bug".into()],
None,
Some("low"),
"Done",
)
.unwrap();
let joined = recorded(&adapter)[0].0.join(" ");
assert_eq!(
joined,
"linear-cli issues update -T=New title -d=new body -l=bug -p=4 -s=Done -- TIX-7"
);
}
#[test]
fn done_via_status_is_true_and_mark_done_is_a_noop() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None);
assert!(adapter.done_via_status());
adapter.mark_done("TIX-7").unwrap();
assert!(recorded(&adapter).is_empty(), "no close call for linear");
}
#[test]
fn set_relations_adds_one_edge_per_relation_with_remote_identifiers() {
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list(""),
CommandOutcome::linear_list(""),
]);
let mut adapter = adapter_with(runner, None);
adapter
.set_relations(
"TIX-1",
&[
MappedRelation::Remote {
field: "blocks".into(),
value: "TIX-2".into(),
},
MappedRelation::Remote {
field: "blocks".into(),
value: "TIX-3".into(),
},
],
)
.unwrap();
let calls = recorded(&adapter);
assert_eq!(
calls[0].0.join(" "),
"linear-cli relations add -r blocks TIX-1 TIX-2"
);
assert_eq!(
calls[1].0.join(" "),
"linear-cli relations add -r blocks TIX-1 TIX-3"
);
}
#[test]
fn list_open_enumerates_then_fetches_each_open_issues_detail() {
let list = r#"[
{"id":"u1","identifier":"TIX-1","title":"T1","priority":3,"state":{"name":"In Progress"}},
{"id":"u2","identifier":"TIX-2","title":"T2","state":{"name":"Done"}}
]"#;
let detail = r##"{"id":"u1","identifier":"TIX-1","title":"T1","description":"full body",
"updatedAt":"2026-05-11T10:00:00Z","state":{"name":"In Progress"},
"assignee":{"name":"Al","email":"al@example.com"},
"labels":{"nodes":[{"name":"bug","color":"#f00"},{"name":"api","color":"#0f0"}]}}"##;
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list(detail),
CommandOutcome::linear_list(list),
]);
let mut adapter = adapter_with(runner, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues.len(), 1, "the Done issue is filtered before the get");
assert_eq!(issues[0].id, "TIX-1");
assert_eq!(issues[0].title, "T1");
assert_eq!(issues[0].description, "full body");
assert_eq!(issues[0].labels, ["bug", "api"]);
assert_eq!(issues[0].state, "In Progress");
assert_eq!(issues[0].assignee.as_deref(), Some("Al"));
assert_eq!(
issues[0].updated_at.as_deref(),
Some("2026-05-11T10:00:00Z")
);
let calls = recorded(&adapter);
assert_eq!(
calls.len(),
2,
"one list call and one get — never a get for the Done issue"
);
assert_eq!(
calls[0].0.join(" "),
"linear-cli issues list -t=Eng -o json"
);
assert_eq!(calls[1].0.join(" "), "linear-cli issues get -o json TIX-1");
}
#[test]
fn a_list_payload_without_detail_fields_does_not_fail_the_import() {
let list = r#"[{"id":"u1","identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
let detail = r#"{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}"#;
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list(detail),
CommandOutcome::linear_list(list),
]);
let mut adapter = adapter_with(runner, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].description, "");
assert_eq!(issues[0].labels, [] as [std::string::String; 0]);
assert_eq!(issues[0].updated_at, None);
}
#[test]
fn create_json_without_identifier_is_a_parse_error() {
let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
let mut adapter = adapter_with(runner, None);
let err = adapter
.create_issue("T", "b", &[], None, None, "todo")
.unwrap_err();
assert!(matches!(err, TixError::External(_)));
assert!(err.to_string().contains("identifier"));
assert!(
err.to_string().contains("linear-cli issues create"),
"the error must name the real subcommand: {err}"
);
}
#[test]
fn a_detail_without_the_identifier_is_a_parse_error() {
let list = r#"[{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list("{}"),
CommandOutcome::linear_list(list),
]);
let mut adapter = adapter_with(runner, None);
let err = adapter.list_open().unwrap_err();
assert!(matches!(err, TixError::External(_)));
assert!(
err.to_string().contains("linear-cli issues get"),
"the error names the failing verb: {err}"
);
}
#[test]
fn failure_message_scrubs_token_from_tool_output() {
let runner = FakeRunner::new(vec![CommandOutcome {
success: false,
status: Some(1),
stdout: String::new(),
stderr: "LINEAR_API_KEY=lin-token leaked".into(),
}]);
let mut adapter = adapter_with(runner, Some("lin-token"));
let err = adapter
.create_issue("T", "b", &[], None, None, "todo")
.unwrap_err();
let text = err.to_string();
assert!(text.contains("<token>"), "expected redaction, got: {text}");
assert!(!text.contains("lin-token"), "secret leaked: {text}");
}
#[test]
fn t036_project_reference_is_read_from_detail_and_never_sent() {
let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
"project":{"name":"One"}}"#;
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list(detail),
CommandOutcome::linear_list(list),
]);
let mut adapter = adapter_with(runner, None);
let issues = adapter.list_open().unwrap();
assert_eq!(
issues[0].projects,
vec![crate::sync::RemoteProject {
id: String::new(),
name: Some("One".into()),
}],
"the real query selects `project{{name}}`, so the id is empty and the name kept"
);
let calls_before = recorded(&adapter).len();
adapter.set_projects("TIX-1", &[]).unwrap();
assert_eq!(
recorded(&adapter).len(),
calls_before,
"an empty membership set must not spawn any command"
);
}
#[test]
fn t038_no_epic_reference_is_read_or_sent() {
let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
"epics":["e-1"]}"#;
let runner = FakeRunner::new(vec![
CommandOutcome::linear_list(detail),
CommandOutcome::linear_list(list),
]);
let mut adapter = adapter_with(runner, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues[0].epics, [] as [crate::sync::RemoteEpic; 0]);
let calls_before = recorded(&adapter).len();
adapter.set_epics("TIX-1", &[]).unwrap();
assert_eq!(
recorded(&adapter).len(),
calls_before,
"an empty reference set must not spawn any command"
);
}
}