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_TOKEN".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 issue create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
))
})
}
}
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![
"issue".into(),
"create".into(),
"--team".into(),
self.team.clone(),
"--title".into(),
title.to_string(),
"--description".into(),
body.to_string(),
];
if !labels.is_empty() {
args.push("--label".to_string());
args.push(labels.join(","));
}
if let Some(p) = priority {
args.push("--priority".to_string());
args.push(p.to_string());
}
args.push("--status".to_string());
args.push(status.to_string());
args.push("--output".to_string());
args.push("json".to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issue 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![
"issue".into(),
"update".into(),
remote_ref.to_string(),
"--title".into(),
title.to_string(),
"--description".into(),
body.to_string(),
];
args.push("--status".to_string());
args.push(status.to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issue 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![
"issue".into(),
"list".into(),
"--team".into(),
self.team.clone(),
"--output".into(),
"json".into(),
];
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`linear-cli issue list` failed", &outcome));
}
let issues: Vec<LinearIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
TixError::External(format!(
"`linear-cli issue list` did not return a JSON array: {e}\n{}",
scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
))
})?;
Ok(issues
.into_iter()
.map(RemoteIssue::from)
.filter(|i| i.state != "Done")
.collect())
}
fn done_via_status(&self) -> bool {
true
}
}
#[derive(Debug, Deserialize)]
struct LinearIssue {
identifier: String,
#[serde(default)]
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
labels: Vec<LinearLabel>,
#[serde(default, alias = "state")]
status: Option<LinearStatus>,
#[serde(default, alias = "updatedAt")]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LinearLabel {
Name(String),
Named { name: String },
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LinearStatus {
Name(String),
Named { name: String },
}
impl From<LinearIssue> for RemoteIssue {
fn from(issue: LinearIssue) -> Self {
let state = match issue.status {
Some(LinearStatus::Name(name)) | Some(LinearStatus::Named { name }) => name,
None => String::new(),
};
RemoteIssue {
id: issue.identifier,
title: issue.title,
description: issue.description.unwrap_or_default(),
labels: issue
.labels
.into_iter()
.map(|l| match l {
LinearLabel::Name(name) | LinearLabel::Named { name } => name,
})
.collect(),
state,
updated_at: issue.updated_at,
assignee: None,
}
}
}
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 {
use super::*;
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_passes_team_title_body_labels_priority_status_and_token() {
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[0], "linear-cli");
let joined = argv.join(" ");
assert!(joined.contains("issue create --team Eng --title Fix --description body --label bug --priority high --status Done --output json"));
assert_eq!(
env,
&[("LINEAR_TOKEN".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("--label"));
}
#[test]
fn update_passes_identifier_and_status() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None);
adapter
.update_issue("TIX-7", "New title", "new body", &[], None, None, "Done")
.unwrap();
let joined = recorded(&adapter)[0].0.join(" ");
assert!(joined.starts_with("linear-cli issue update TIX-7 "));
assert!(joined.contains("--title New title"));
assert!(joined.contains("--description new body"));
assert!(joined.contains("--status Done"));
}
#[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 list_open_parses_json_object_status_and_filters_done() {
let payload = r#"[
{"identifier":"TIX-1","title":"T1","description":"d","labels":["bug"],"status":{"name":"In Progress"},"updated_at":"2026-05-11T10:00:00Z"},
{"identifier":"TIX-2","title":"T2","status":"Done"}
]"#;
let runner = FakeRunner::new(vec![CommandOutcome::linear_list(payload)]);
let mut adapter = adapter_with(runner, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "TIX-1");
assert_eq!(issues[0].state, "In Progress");
assert_eq!(issues[0].labels, ["bug"]);
assert_eq!(
issues[0].updated_at.as_deref(),
Some("2026-05-11T10:00:00Z")
);
}
#[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"));
}
#[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_TOKEN=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}");
}
}