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
179#[derive(Debug, Deserialize)]
181struct LinearIssue {
182 identifier: String,
183 #[serde(default)]
184 title: String,
185 #[serde(default)]
186 description: Option<String>,
187 #[serde(default)]
188 labels: Vec<LinearLabel>,
189 #[serde(default, alias = "state")]
190 status: Option<LinearStatus>,
191 #[serde(default, alias = "updatedAt")]
192 updated_at: Option<String>,
193}
194
195#[derive(Debug, Deserialize)]
197#[serde(untagged)]
198enum LinearLabel {
199 Name(String),
200 Named { name: String },
201}
202
203#[derive(Debug, Deserialize)]
205#[serde(untagged)]
206enum LinearStatus {
207 Name(String),
208 Named { name: String },
209}
210
211impl From<LinearIssue> for RemoteIssue {
212 fn from(issue: LinearIssue) -> Self {
213 let state = match issue.status {
214 Some(LinearStatus::Name(name)) | Some(LinearStatus::Named { name }) => name,
215 None => String::new(),
216 };
217 RemoteIssue {
218 id: issue.identifier,
219 title: issue.title,
220 description: issue.description.unwrap_or_default(),
221 labels: issue
222 .labels
223 .into_iter()
224 .map(|l| match l {
225 LinearLabel::Name(name) | LinearLabel::Named { name } => name,
226 })
227 .collect(),
228 state,
229 updated_at: issue.updated_at,
230 assignee: None,
231 }
232 }
233}
234
235impl CommandOutcome {
237 #[must_use]
238 pub fn linear_created(identifier: &str) -> CommandOutcome {
239 CommandOutcome {
240 success: true,
241 status: Some(0),
242 stdout: format!(
243 "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
244 ),
245 stderr: String::new(),
246 }
247 }
248
249 #[must_use]
250 pub fn linear_list(payload: &str) -> CommandOutcome {
251 CommandOutcome {
252 success: true,
253 status: Some(0),
254 stdout: payload.to_string(),
255 stderr: String::new(),
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::sync::glab::{FakeRunner, RecordedCall};
264
265 fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
266 LinearAdapter::new(runner, token.map(String::from), "Eng".into())
267 }
268
269 fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
270 adapter
271 .runner
272 .as_any()
273 .downcast_ref::<FakeRunner>()
274 .expect("adapter must hold a FakeRunner")
275 .take_calls()
276 }
277
278 #[test]
279 fn create_passes_team_title_body_labels_priority_status_and_token() {
280 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
281 let mut adapter = adapter_with(runner, Some("lin-token"));
282 let id = adapter
283 .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
284 .unwrap();
285 assert_eq!(id, "TIX-7");
286 let (argv, env) = &recorded(&adapter)[0];
287 assert_eq!(argv[0], "linear-cli");
288 let joined = argv.join(" ");
289 assert!(joined.contains("issue create --team Eng --title Fix --description body --label bug --priority high --status Done --output json"));
290 assert_eq!(
291 env,
292 &[("LINEAR_TOKEN".to_string(), "lin-token".to_string())]
293 );
294 }
295
296 #[test]
297 fn create_without_labels_omits_label_flag() {
298 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
299 let mut adapter = adapter_with(runner, None);
300 adapter
301 .create_issue("T", "b", &[], None, None, "todo")
302 .unwrap();
303 let joined = recorded(&adapter)[0].0.join(" ");
304 assert!(!joined.contains("--label"));
305 }
306
307 #[test]
308 fn update_passes_identifier_and_status() {
309 let runner = FakeRunner::new(vec![]);
310 let mut adapter = adapter_with(runner, None);
311 adapter
312 .update_issue("TIX-7", "New title", "new body", &[], None, None, "Done")
313 .unwrap();
314 let joined = recorded(&adapter)[0].0.join(" ");
315 assert!(joined.starts_with("linear-cli issue update TIX-7 "));
316 assert!(joined.contains("--title New title"));
317 assert!(joined.contains("--description new body"));
318 assert!(joined.contains("--status Done"));
319 }
320
321 #[test]
322 fn done_via_status_is_true_and_mark_done_is_a_noop() {
323 let runner = FakeRunner::new(vec![]);
324 let mut adapter = adapter_with(runner, None);
325 assert!(adapter.done_via_status());
326 adapter.mark_done("TIX-7").unwrap();
327 assert!(recorded(&adapter).is_empty(), "no close call for linear");
328 }
329
330 #[test]
331 fn list_open_parses_json_object_status_and_filters_done() {
332 let payload = r#"[
333 {"identifier":"TIX-1","title":"T1","description":"d","labels":["bug"],"status":{"name":"In Progress"},"updated_at":"2026-05-11T10:00:00Z"},
334 {"identifier":"TIX-2","title":"T2","status":"Done"}
335 ]"#;
336 let runner = FakeRunner::new(vec![CommandOutcome::linear_list(payload)]);
337 let mut adapter = adapter_with(runner, None);
338 let issues = adapter.list_open().unwrap();
339 assert_eq!(issues.len(), 1);
340 assert_eq!(issues[0].id, "TIX-1");
341 assert_eq!(issues[0].state, "In Progress");
342 assert_eq!(issues[0].labels, ["bug"]);
343 assert_eq!(
344 issues[0].updated_at.as_deref(),
345 Some("2026-05-11T10:00:00Z")
346 );
347 }
348
349 #[test]
350 fn create_json_without_identifier_is_a_parse_error() {
351 let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
352 let mut adapter = adapter_with(runner, None);
353 let err = adapter
354 .create_issue("T", "b", &[], None, None, "todo")
355 .unwrap_err();
356 assert!(matches!(err, TixError::External(_)));
357 assert!(err.to_string().contains("identifier"));
358 }
359
360 #[test]
361 fn failure_message_scrubs_token_from_tool_output() {
362 let runner = FakeRunner::new(vec![CommandOutcome {
363 success: false,
364 status: Some(1),
365 stdout: String::new(),
366 stderr: "LINEAR_TOKEN=lin-token leaked".into(),
367 }]);
368 let mut adapter = adapter_with(runner, Some("lin-token"));
369 let err = adapter
370 .create_issue("T", "b", &[], None, None, "todo")
371 .unwrap_err();
372 let text = err.to_string();
373 assert!(text.contains("<token>"), "expected redaction, got: {text}");
374 assert!(!text.contains("lin-token"), "secret leaked: {text}");
375 }
376}