1use serde::Deserialize;
10
11use crate::error::{Result, TixError};
12use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
13
14pub struct LinearAdapter {
18 runner: Box<dyn CommandRunner>,
19 token: Option<String>,
20 team: String,
21}
22
23impl LinearAdapter {
24 pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
25 LinearAdapter {
26 runner: Box::new(runner),
27 token,
28 team,
29 }
30 }
31
32 fn child_env(&self) -> Vec<(String, String)> {
33 match &self.token {
34 Some(token) => vec![("LINEAR_TOKEN".to_string(), token.clone())],
35 None => Vec::new(),
36 }
37 }
38
39 fn run(&self, args: &[String]) -> Result<CommandOutcome> {
40 self.runner
41 .run(Tool::Linear.binary(), args, &self.child_env())
42 }
43
44 fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
45 let mut message = format!("{context}: {}", outcome.text().trim());
46 if let Some(secret) = &self.token {
47 message = scrub_secret(&message, secret);
48 }
49 TixError::External(message)
50 }
51
52 fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
54 serde_json::from_str::<serde_json::Value>(&outcome.stdout)
55 .ok()
56 .and_then(|value| {
57 value
58 .get("identifier")
59 .and_then(serde_json::Value::as_str)
60 .map(str::to_string)
61 })
62 .ok_or_else(|| {
63 TixError::External(format!(
64 "`linear-cli issue create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
65 scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
66 ))
67 })
68 }
69}
70
71impl TrackerAdapter for LinearAdapter {
72 fn tool(&self) -> Tool {
73 Tool::Linear
74 }
75
76 fn create_issue(
77 &mut self,
78 title: &str,
79 body: &str,
80 labels: &[String],
81 _assignee: Option<&str>,
82 priority: Option<&str>,
83 status: &str,
84 ) -> Result<String> {
85 let mut args: Vec<String> = vec![
86 "issue".into(),
87 "create".into(),
88 "--team".into(),
89 self.team.clone(),
90 "--title".into(),
91 title.to_string(),
92 "--description".into(),
93 body.to_string(),
94 ];
95 if !labels.is_empty() {
96 args.push("--label".to_string());
97 args.push(labels.join(","));
98 }
99 if let Some(p) = priority {
100 args.push("--priority".to_string());
101 args.push(p.to_string());
102 }
103 args.push("--status".to_string());
104 args.push(status.to_string());
105 args.push("--output".to_string());
106 args.push("json".to_string());
107 let outcome = self.run(&args)?;
108 if !outcome.success {
109 return Err(self.external("`linear-cli issue create` failed", &outcome));
110 }
111 self.parse_identifier(&outcome)
112 }
113
114 fn update_issue(
115 &mut self,
116 remote_ref: &str,
117 title: &str,
118 body: &str,
119 _labels: &[String],
120 _assignee: Option<&str>,
121 _priority: Option<&str>,
122 status: &str,
123 ) -> Result<()> {
124 let mut args: Vec<String> = vec![
125 "issue".into(),
126 "update".into(),
127 remote_ref.to_string(),
128 "--title".into(),
129 title.to_string(),
130 "--description".into(),
131 body.to_string(),
132 ];
133 args.push("--status".to_string());
134 args.push(status.to_string());
135 let outcome = self.run(&args)?;
136 if !outcome.success {
137 return Err(self.external("`linear-cli issue update` failed", &outcome));
138 }
139 Ok(())
140 }
141
142 fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
145 Ok(())
146 }
147
148 fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
149 let args: Vec<String> = vec![
150 "issue".into(),
151 "list".into(),
152 "--team".into(),
153 self.team.clone(),
154 "--output".into(),
155 "json".into(),
156 ];
157 let outcome = self.run(&args)?;
158 if !outcome.success {
159 return Err(self.external("`linear-cli issue list` failed", &outcome));
160 }
161 let issues: Vec<LinearIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
162 TixError::External(format!(
163 "`linear-cli issue list` did not return a JSON array: {e}\n{}",
164 scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
165 ))
166 })?;
167 Ok(issues
168 .into_iter()
169 .map(RemoteIssue::from)
170 .filter(|i| i.state != "Done")
171 .collect())
172 }
173
174 fn done_via_status(&self) -> bool {
175 true
176 }
177
178 fn set_relations(
186 &mut self,
187 remote_ref: &str,
188 relations: &[super::MappedRelation],
189 ) -> Result<()> {
190 for relation in relations {
191 let super::MappedRelation::Remote { value, .. } = relation else {
192 continue;
193 };
194 let args: Vec<String> = vec![
195 "relation".into(),
196 "create".into(),
197 "--issue".into(),
198 remote_ref.to_string(),
199 "--type".into(),
200 value.clone(),
201 ];
202 let outcome = self.run(&args)?;
203 if !outcome.success {
204 return Err(self.external("`linear-cli relation create` failed", &outcome));
205 }
206 }
207 Ok(())
208 }
209}
210
211#[derive(Debug, Deserialize)]
213struct LinearIssue {
214 identifier: String,
215 #[serde(default)]
216 title: String,
217 #[serde(default)]
218 description: Option<String>,
219 #[serde(default)]
220 labels: Vec<LinearLabel>,
221 #[serde(default, alias = "state")]
222 status: Option<LinearStatus>,
223 #[serde(default, alias = "updatedAt")]
224 updated_at: Option<String>,
225 #[serde(default)]
233 parent: Option<serde_json::Value>,
234}
235
236#[derive(Debug, Deserialize)]
238#[serde(untagged)]
239enum LinearLabel {
240 Name(String),
241 Named { name: String },
242}
243
244#[derive(Debug, Deserialize)]
246#[serde(untagged)]
247enum LinearStatus {
248 Name(String),
249 Named { name: String },
250}
251
252impl From<LinearIssue> for RemoteIssue {
253 fn from(issue: LinearIssue) -> Self {
254 let state = match issue.status {
255 Some(LinearStatus::Name(name)) | Some(LinearStatus::Named { name }) => name,
256 None => String::new(),
257 };
258 RemoteIssue {
259 id: issue.identifier,
260 title: issue.title,
261 description: issue.description.unwrap_or_default(),
262 labels: issue
263 .labels
264 .into_iter()
265 .map(|l| match l {
266 LinearLabel::Name(name) | LinearLabel::Named { name } => name,
267 })
268 .collect(),
269 state,
270 updated_at: issue.updated_at,
271 assignee: None,
272 parent: linear_parent_id(issue.parent.as_ref()),
278 }
279 }
280}
281
282fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
289 fn one(value: &serde_json::Value) -> Option<String> {
290 match value {
291 serde_json::Value::String(s) => Some(s.clone()),
292 serde_json::Value::Object(map) => map
293 .get("id")
294 .or_else(|| map.get("identifier"))
295 .and_then(|v| v.as_str())
296 .map(str::to_string),
297 _ => None,
298 }
299 }
300 match value? {
301 serde_json::Value::Array(items) => items.first().and_then(one),
302 other => one(other),
303 }
304}
305
306impl CommandOutcome {
308 #[must_use]
309 pub fn linear_created(identifier: &str) -> CommandOutcome {
310 CommandOutcome {
311 success: true,
312 status: Some(0),
313 stdout: format!(
314 "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
315 ),
316 stderr: String::new(),
317 }
318 }
319
320 #[must_use]
321 pub fn linear_list(payload: &str) -> CommandOutcome {
322 CommandOutcome {
323 success: true,
324 status: Some(0),
325 stdout: payload.to_string(),
326 stderr: String::new(),
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::sync::glab::{FakeRunner, RecordedCall};
335
336 fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
337 LinearAdapter::new(runner, token.map(String::from), "Eng".into())
338 }
339
340 fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
341 adapter
342 .runner
343 .as_any()
344 .downcast_ref::<FakeRunner>()
345 .expect("adapter must hold a FakeRunner")
346 .take_calls()
347 }
348
349 #[test]
350 fn create_passes_team_title_body_labels_priority_status_and_token() {
351 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
352 let mut adapter = adapter_with(runner, Some("lin-token"));
353 let id = adapter
354 .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
355 .unwrap();
356 assert_eq!(id, "TIX-7");
357 let (argv, env) = &recorded(&adapter)[0];
358 assert_eq!(argv[0], "linear-cli");
359 let joined = argv.join(" ");
360 assert!(joined.contains("issue create --team Eng --title Fix --description body --label bug --priority high --status Done --output json"));
361 assert_eq!(
362 env,
363 &[("LINEAR_TOKEN".to_string(), "lin-token".to_string())]
364 );
365 }
366
367 #[test]
368 fn create_without_labels_omits_label_flag() {
369 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
370 let mut adapter = adapter_with(runner, None);
371 adapter
372 .create_issue("T", "b", &[], None, None, "todo")
373 .unwrap();
374 let joined = recorded(&adapter)[0].0.join(" ");
375 assert!(!joined.contains("--label"));
376 }
377
378 #[test]
379 fn update_passes_identifier_and_status() {
380 let runner = FakeRunner::new(vec![]);
381 let mut adapter = adapter_with(runner, None);
382 adapter
383 .update_issue("TIX-7", "New title", "new body", &[], None, None, "Done")
384 .unwrap();
385 let joined = recorded(&adapter)[0].0.join(" ");
386 assert!(joined.starts_with("linear-cli issue update TIX-7 "));
387 assert!(joined.contains("--title New title"));
388 assert!(joined.contains("--description new body"));
389 assert!(joined.contains("--status Done"));
390 }
391
392 #[test]
393 fn done_via_status_is_true_and_mark_done_is_a_noop() {
394 let runner = FakeRunner::new(vec![]);
395 let mut adapter = adapter_with(runner, None);
396 assert!(adapter.done_via_status());
397 adapter.mark_done("TIX-7").unwrap();
398 assert!(recorded(&adapter).is_empty(), "no close call for linear");
399 }
400
401 #[test]
402 fn list_open_parses_json_object_status_and_filters_done() {
403 let payload = r#"[
404 {"identifier":"TIX-1","title":"T1","description":"d","labels":["bug"],"status":{"name":"In Progress"},"updated_at":"2026-05-11T10:00:00Z"},
405 {"identifier":"TIX-2","title":"T2","status":"Done"}
406 ]"#;
407 let runner = FakeRunner::new(vec![CommandOutcome::linear_list(payload)]);
408 let mut adapter = adapter_with(runner, None);
409 let issues = adapter.list_open().unwrap();
410 assert_eq!(issues.len(), 1);
411 assert_eq!(issues[0].id, "TIX-1");
412 assert_eq!(issues[0].state, "In Progress");
413 assert_eq!(issues[0].labels, ["bug"]);
414 assert_eq!(
415 issues[0].updated_at.as_deref(),
416 Some("2026-05-11T10:00:00Z")
417 );
418 }
419
420 #[test]
421 fn create_json_without_identifier_is_a_parse_error() {
422 let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
423 let mut adapter = adapter_with(runner, None);
424 let err = adapter
425 .create_issue("T", "b", &[], None, None, "todo")
426 .unwrap_err();
427 assert!(matches!(err, TixError::External(_)));
428 assert!(err.to_string().contains("identifier"));
429 }
430
431 #[test]
432 fn failure_message_scrubs_token_from_tool_output() {
433 let runner = FakeRunner::new(vec![CommandOutcome {
434 success: false,
435 status: Some(1),
436 stdout: String::new(),
437 stderr: "LINEAR_TOKEN=lin-token leaked".into(),
438 }]);
439 let mut adapter = adapter_with(runner, Some("lin-token"));
440 let err = adapter
441 .create_issue("T", "b", &[], None, None, "todo")
442 .unwrap_err();
443 let text = err.to_string();
444 assert!(text.contains("<token>"), "expected redaction, got: {text}");
445 assert!(!text.contains("lin-token"), "secret leaked: {text}");
446 }
447}