tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Linear adapter: shells out to the `linear-cli` CLI under
//! `contracts/external-tools.md` (Linear row).
//!
//! Linear conveys workflow stage through the `--status` flag (done → `Done`,
//! otherwise the first work column), so this adapter sets `done_via_status`
//! and never issues a separate close. Create/list use `--output json` to
//! recover the `identifier` / list payload.

use serde::Deserialize;

use crate::error::{Result, TixError};
use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};

/// The `linear-cli` adapter. Spawns `linear-cli issue <create|update|list>`
/// with the configured team and, when a secret is configured, the
/// `LINEAR_TOKEN` env pair.
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)
    }

    /// Parse `linear-cli issue create` stdout for the `identifier`.
    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(())
    }

    /// Linear conveys done via `--status Done`; there is no close command in
    /// the documented template.
    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
    }
}

/// A single entry from `linear-cli issue list --output json`.
#[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>,
}

/// Labels arrive either as plain strings or as `{"name":..}` / `{"value":..}`.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LinearLabel {
    Name(String),
    Named { name: String },
}

/// `status` arrives either as a plain string or as `{"name":..}`.
#[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,
        }
    }
}

/// Response helpers shaped like `linear-cli` JSON output.
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}");
    }
}