use std::collections::HashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::{ExternalSignal, LinearSourceConfig, EXTERNAL_SOURCE_CONFIDENCE};
const LINEAR_CONFIDENCE: f64 = EXTERNAL_SOURCE_CONFIDENCE;
fn linear_key_regex() -> Regex {
Regex::new(r"\b([A-Z][A-Z0-9]{0,9}-\d{1,7})").expect("static regex is valid")
}
pub fn extract_linear_keys(message: &str) -> Vec<String> {
let re = linear_key_regex();
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(message) {
if let Some(key) = cap.get(1) {
let end = key.end();
if message
.as_bytes()
.get(end)
.is_some_and(|b| b.is_ascii_digit())
{
continue;
}
let k = key.as_str().to_string();
if seen.insert(k.clone()) {
out.push(k);
}
}
}
out
}
pub fn matches_team_key(key: &str, team_keys: &[String]) -> bool {
if team_keys.is_empty() {
return true;
}
let prefix = key.split('-').next().unwrap_or("");
team_keys.iter().any(|tk| tk == prefix)
}
fn issue_query(key: &str) -> serde_json::Value {
serde_json::json!({
"query": format!(
r#"query {{
issue(id: "{key}") {{
identifier
type {{ name }}
labels {{ nodes {{ name }} }}
cycle {{ name }}
}}
}}"#
)
})
}
#[derive(Debug, Deserialize)]
pub struct LinearResponse {
pub data: Option<LinearData>,
}
#[derive(Debug, Deserialize)]
pub struct LinearData {
pub issue: Option<LinearIssue>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LinearIssue {
pub identifier: String,
#[serde(rename = "type")]
pub issue_type: Option<LinearIssueType>,
#[serde(default)]
pub labels: LinearLabels,
pub cycle: Option<LinearCycle>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LinearIssueType {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct LinearLabels {
#[serde(default)]
pub nodes: Vec<LinearLabel>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LinearLabel {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LinearCycle {
pub name: String,
}
pub fn classify_issue(issue: &LinearIssue, config: &LinearSourceConfig) -> Option<ExternalSignal> {
let mappings = &config.field_mappings;
if let Some(it) = &issue.issue_type {
if let Some(cat) = mappings.issue_type.get(&it.name) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: LINEAR_CONFIDENCE,
source: format!("linear:issue_type:{}", it.name),
});
}
}
for label in &issue.labels.nodes {
if let Some(cat) = mappings.labels.get(label.name.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: LINEAR_CONFIDENCE,
source: format!("linear:label:{}", label.name),
});
}
}
if let Some(cycle) = &issue.cycle {
if let Some(cat) = mappings.cycle.get(cycle.name.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: LINEAR_CONFIDENCE,
source: format!("linear:cycle:{}", cycle.name),
});
}
}
None
}
pub async fn fetch_issue(
client: &reqwest::Client,
config: &LinearSourceConfig,
key: &str,
base_url_override: Option<&str>,
) -> Option<LinearIssue> {
let token = match std::env::var(&config.api_key_env) {
Ok(t) if !t.is_empty() => t,
_ => {
warn!(
api_key_env = %config.api_key_env,
"Linear API key env var `{}` is not set — did you `export {}` before running tga?",
config.api_key_env, config.api_key_env,
);
return None;
}
};
let base = base_url_override.unwrap_or("https://api.linear.app");
let url = format!("{base}/graphql");
let body = issue_query(key);
let resp = match client
.post(&url)
.header("Authorization", &token)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(key, error = %e, "Linear GraphQL request failed; skipping");
return None;
}
};
if !resp.status().is_success() {
warn!(
key,
status = %resp.status(),
"Linear GraphQL API returned non-success status; skipping"
);
return None;
}
match resp.json::<LinearResponse>().await {
Ok(LinearResponse {
data: Some(LinearData { issue: Some(issue) }),
}) => Some(issue),
Ok(_) => {
warn!(
key,
"Linear GraphQL response contained no issue data; skipping"
);
None
}
Err(e) => {
warn!(key, error = %e, "failed to parse Linear GraphQL response; skipping");
None
}
}
}
pub async fn fetch_issues_batch(
client: &reqwest::Client,
config: &LinearSourceConfig,
keys: &[String],
base_url_override: Option<&str>,
) -> HashMap<String, Option<ExternalSignal>> {
let mut out = HashMap::new();
for key in keys {
if out.contains_key(key) {
continue;
}
let issue = fetch_issue(client, config, key, base_url_override).await;
let signal = issue.and_then(|iss| classify_issue(&iss, config));
out.insert(key.clone(), signal);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_linear_keys_single() {
assert_eq!(extract_linear_keys("ENG-1234 fix null"), vec!["ENG-1234"]);
assert_eq!(
extract_linear_keys("fix: FRONTEND-99 update pipeline"),
vec!["FRONTEND-99"]
);
assert_eq!(extract_linear_keys("BE-456"), vec!["BE-456"]);
}
#[test]
fn extract_linear_keys_multiple_and_dedup() {
let keys = extract_linear_keys("ENG-1 and BE-2 relate to FE-3 and ENG-1 again");
assert_eq!(keys, vec!["ENG-1", "BE-2", "FE-3"]);
}
#[test]
fn extract_linear_keys_ignores_lowercase() {
assert!(extract_linear_keys("eng-123 lowercase").is_empty());
assert!(extract_linear_keys("no ticket here").is_empty());
}
#[test]
fn team_key_filter_empty_allows_all() {
assert!(matches_team_key("ENG-1", &[]));
assert!(matches_team_key("ANY-999", &[]));
}
#[test]
fn team_key_filter_restricts_to_configured_prefixes() {
let teams = vec!["ENG".to_string(), "BE".to_string()];
assert!(matches_team_key("ENG-1", &teams));
assert!(matches_team_key("BE-42", &teams));
assert!(!matches_team_key("JIRA-1234", &teams));
assert!(!matches_team_key("FE-10", &teams));
}
#[test]
fn classify_issue_type_wins_over_labels() {
use std::collections::HashMap;
let issue = LinearIssue {
identifier: "ENG-1".to_string(),
issue_type: Some(LinearIssueType {
name: "Bug".to_string(),
}),
labels: LinearLabels {
nodes: vec![LinearLabel {
name: "enhancement".to_string(),
}],
},
cycle: Some(LinearCycle {
name: "Sprint 42".to_string(),
}),
};
let config = LinearSourceConfig {
api_key_env: "LINEAR_API_TOKEN".to_string(), team_keys: vec![],
field_mappings: crate::classify::sources::LinearFieldMappings {
issue_type: {
let mut m = HashMap::new();
m.insert("Bug".to_string(), "bug_fix".to_string());
m
},
labels: {
let mut m = HashMap::new();
m.insert("enhancement".to_string(), "new_feature".to_string());
m
},
cycle: {
let mut m = HashMap::new();
m.insert("Sprint 42".to_string(), "sprint_delivery".to_string());
m
},
},
};
let signal = classify_issue(&issue, &config).expect("should match");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("issue_type"));
}
#[test]
fn classify_falls_through_to_labels() {
use std::collections::HashMap;
let issue = LinearIssue {
identifier: "ENG-2".to_string(),
issue_type: Some(LinearIssueType {
name: "Epic".to_string(), }),
labels: LinearLabels {
nodes: vec![LinearLabel {
name: "security".to_string(),
}],
},
cycle: None,
};
let config = LinearSourceConfig {
api_key_env: "LINEAR_API_TOKEN".to_string(), team_keys: vec![],
field_mappings: crate::classify::sources::LinearFieldMappings {
issue_type: HashMap::new(),
labels: {
let mut m = HashMap::new();
m.insert("security".to_string(), "security".to_string());
m
},
cycle: HashMap::new(),
},
};
let signal = classify_issue(&issue, &config).expect("should match via label");
assert_eq!(signal.category, "security");
assert!(signal.source.contains("label"));
}
#[test]
fn classify_returns_none_on_no_match() {
use std::collections::HashMap;
let issue = LinearIssue {
identifier: "ENG-3".to_string(),
issue_type: Some(LinearIssueType {
name: "Unknown".to_string(),
}),
labels: LinearLabels { nodes: vec![] },
cycle: None,
};
let config = LinearSourceConfig {
api_key_env: "LINEAR_API_TOKEN".to_string(), team_keys: vec![],
field_mappings: crate::classify::sources::LinearFieldMappings {
issue_type: HashMap::new(),
labels: HashMap::new(),
cycle: HashMap::new(),
},
};
assert!(classify_issue(&issue, &config).is_none());
}
#[test]
fn linear_source_config_deserializes() {
use crate::classify::sources::SourceConfig;
let yaml = r#"
type: linear
api_key_env: LINEAR_API_TOKEN
team_keys: ["ENG", "BE"]
field_mappings:
issue_type:
Bug: bug_fix
Feature: new_feature
labels:
security: security
cycle: {}
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Linear(l) => {
assert_eq!(l.api_key_env, "LINEAR_API_TOKEN"); assert_eq!(l.team_keys, vec!["ENG", "BE"]);
assert_eq!(
l.field_mappings.issue_type.get("Bug"),
Some(&"bug_fix".to_string())
);
assert_eq!(
l.field_mappings.labels.get("security"),
Some(&"security".to_string())
);
}
other => panic!("expected Linear variant, got {other:?}"),
}
}
#[test]
fn linear_source_config_unknown_field_is_rejected() {
let yaml = r#"
type: linear
api_key: MY_KEY
team_keys: []
field_mappings:
issue_type: {}
labels: {}
cycle: {}
"#;
let result: Result<crate::classify::sources::SourceConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "unknown field must be rejected");
}
#[tokio::test]
async fn fetch_and_classify_via_wiremock() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = serde_json::json!({
"data": {
"issue": {
"identifier": "ENG-99",
"type": {"name": "Bug"},
"labels": {"nodes": [{"name": "ktlo"}]},
"cycle": null
}
}
});
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("LINEAR_API_TOKEN_WT", "test-token") };
use std::collections::HashMap;
let config = LinearSourceConfig {
api_key_env: "LINEAR_API_TOKEN_WT".to_string(), team_keys: vec![],
field_mappings: crate::classify::sources::LinearFieldMappings {
issue_type: {
let mut m = HashMap::new();
m.insert("Bug".to_string(), "bug_fix".to_string());
m
},
labels: HashMap::new(),
cycle: HashMap::new(),
},
};
let client = reqwest::Client::new();
let issue = fetch_issue(&client, &config, "ENG-99", Some(&server.uri()))
.await
.expect("fetch should succeed");
let signal = classify_issue(&issue, &config).expect("should classify");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("issue_type"));
unsafe { std::env::remove_var("LINEAR_API_TOKEN_WT") };
}
}