use std::collections::HashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::{ExternalSignal, ShortcutSourceConfig, EXTERNAL_SOURCE_CONFIDENCE};
fn bracket_ref_regex() -> Regex {
Regex::new(r"\[ch(\d+)\]").expect("static regex is valid")
}
fn sc_ref_regex() -> Regex {
Regex::new(r"\bsc-(\d+)\b").expect("static regex is valid")
}
pub fn extract_shortcut_ids(message: &str) -> Vec<u64> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in bracket_ref_regex().captures_iter(message) {
if let Some(num_m) = cap.get(1) {
let id: u64 = num_m.as_str().parse().unwrap_or(0);
if id > 0 && seen.insert(id) {
out.push(id);
}
}
}
for cap in sc_ref_regex().captures_iter(message) {
if let Some(num_m) = cap.get(1) {
let id: u64 = num_m.as_str().parse().unwrap_or(0);
if id > 0 && seen.insert(id) {
out.push(id);
}
}
}
out
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ShortcutStory {
pub id: u64,
pub story_type: String,
#[serde(default)]
pub labels: Vec<ShortcutLabel>,
pub workflow_state: Option<ShortcutWorkflowState>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ShortcutLabel {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ShortcutWorkflowState {
pub name: String,
}
pub fn classify_story(
story: &ShortcutStory,
config: &ShortcutSourceConfig,
) -> Option<ExternalSignal> {
let mappings = &config.field_mappings;
if let Some(cat) = mappings.story_type.get(&story.story_type) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("shortcut:story_type:{}", story.story_type),
});
}
for label in &story.labels {
if let Some(cat) = mappings.labels.get(label.name.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("shortcut:label:{}", label.name),
});
}
}
if let Some(ws) = &story.workflow_state {
if let Some(cat) = mappings.workflow_state.get(ws.name.as_str()) {
return Some(ExternalSignal {
category: cat.clone(),
confidence: EXTERNAL_SOURCE_CONFIDENCE,
source: format!("shortcut:workflow_state:{}", ws.name),
});
}
}
None
}
pub async fn fetch_story(
client: &reqwest::Client,
config: &ShortcutSourceConfig,
id: u64,
base_url_override: Option<&str>,
) -> Option<ShortcutStory> {
let token = match std::env::var(&config.api_token_env) {
Ok(t) if !t.is_empty() => t,
_ => {
warn!(
api_token_env = %config.api_token_env,
"Shortcut API token env var `{}` is not set — did you `export {}` before running tga?",
config.api_token_env, config.api_token_env,
);
return None;
}
};
let base = base_url_override.unwrap_or("https://api.app.shortcut.com");
let url = format!("{base}/api/v3/stories/{id}");
let resp = match client
.get(&url)
.header("Shortcut-Token", &token)
.header("Content-Type", "application/json")
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(id, error = %e, "Shortcut API request failed; skipping");
return None;
}
};
if !resp.status().is_success() {
warn!(
id,
status = %resp.status(),
"Shortcut API returned non-success status; skipping"
);
return None;
}
match resp.json::<ShortcutStory>().await {
Ok(story) => Some(story),
Err(e) => {
warn!(id, error = %e, "failed to parse Shortcut story response; skipping");
None
}
}
}
pub async fn fetch_stories_batch(
client: &reqwest::Client,
config: &ShortcutSourceConfig,
ids: &[u64],
base_url_override: Option<&str>,
) -> HashMap<String, Option<ExternalSignal>> {
let mut out = HashMap::new();
for &id in ids {
let key = id.to_string();
if out.contains_key(&key) {
continue;
}
let story = fetch_story(client, config, id, base_url_override).await;
let signal = story.and_then(|s| classify_story(&s, config));
out.insert(key, signal);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_shortcut_ids_bracket_form() {
let ids = extract_shortcut_ids("fix: [ch1234] resolve null pointer");
assert_eq!(ids, vec![1234u64]);
}
#[test]
fn extract_shortcut_ids_sc_form() {
let ids = extract_shortcut_ids("feat: sc-42 add user profile");
assert_eq!(ids, vec![42u64]);
}
#[test]
fn extract_shortcut_ids_both_forms_and_dedup() {
let ids = extract_shortcut_ids("fix: [ch100] and sc-200 (see [ch100] again)");
assert_eq!(ids, vec![100u64, 200u64]);
}
#[test]
fn extract_shortcut_ids_no_match() {
assert!(extract_shortcut_ids("feat: add login flow").is_empty());
assert!(extract_shortcut_ids("fix: PROJ-123 jira style").is_empty());
}
#[test]
fn classify_story_type_wins_over_labels() {
use std::collections::HashMap;
let story = ShortcutStory {
id: 1,
story_type: "bug".to_string(),
labels: vec![ShortcutLabel {
name: "enhancement".to_string(),
}],
workflow_state: Some(ShortcutWorkflowState {
name: "Done".to_string(),
}),
};
let config = ShortcutSourceConfig {
api_token_env: "SHORTCUT_API_TOKEN".to_string(), workspace_id: "myco".to_string(),
field_mappings: crate::classify::sources::ShortcutFieldMappings {
story_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
},
workflow_state: {
let mut m = HashMap::new();
m.insert("Done".to_string(), "completed".to_string());
m
},
},
};
let signal = classify_story(&story, &config).expect("should match");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("story_type"));
}
#[test]
fn classify_falls_through_to_labels() {
use std::collections::HashMap;
let story = ShortcutStory {
id: 2,
story_type: "chore".to_string(), labels: vec![ShortcutLabel {
name: "security".to_string(),
}],
workflow_state: None,
};
let config = ShortcutSourceConfig {
api_token_env: "SHORTCUT_API_TOKEN".to_string(), workspace_id: "myco".to_string(),
field_mappings: crate::classify::sources::ShortcutFieldMappings {
story_type: HashMap::new(),
labels: {
let mut m = HashMap::new();
m.insert("security".to_string(), "security".to_string());
m
},
workflow_state: HashMap::new(),
},
};
let signal = classify_story(&story, &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 story = ShortcutStory {
id: 3,
story_type: "unknown_type".to_string(),
labels: vec![],
workflow_state: None,
};
let config = ShortcutSourceConfig {
api_token_env: "SHORTCUT_API_TOKEN".to_string(), workspace_id: "myco".to_string(),
field_mappings: crate::classify::sources::ShortcutFieldMappings {
story_type: HashMap::new(),
labels: HashMap::new(),
workflow_state: HashMap::new(),
},
};
assert!(classify_story(&story, &config).is_none());
}
#[test]
fn shortcut_source_config_deserializes() {
use crate::classify::sources::SourceConfig;
let yaml = r#"
type: shortcut
api_token_env: SHORTCUT_API_TOKEN
workspace_id: myco
field_mappings:
story_type:
bug: bug_fix
feature: new_feature
chore: tech_debt_refactoring
labels:
security: security
workflow_state: {}
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Shortcut(s) => {
assert_eq!(s.api_token_env, "SHORTCUT_API_TOKEN"); assert_eq!(s.workspace_id, "myco");
assert_eq!(
s.field_mappings.story_type.get("bug"),
Some(&"bug_fix".to_string())
);
assert_eq!(
s.field_mappings.story_type.get("feature"),
Some(&"new_feature".to_string())
);
}
other => panic!("expected Shortcut variant, got {other:?}"),
}
}
#[test]
fn shortcut_source_config_unknown_field_is_rejected() {
let yaml = r#"
type: shortcut
api_token_env: SHORTCUT_API_TOKEN
workspace_id: myco
workspace_slug: myco
field_mappings:
story_type: {}
labels: {}
workflow_state: {}
"#;
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!({
"id": 55,
"story_type": "bug",
"labels": [{"name": "ktlo"}],
"workflow_state": {"name": "Done"}
});
Mock::given(method("GET"))
.and(path("/api/v3/stories/55"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("SHORTCUT_TOKEN_WT", "test-token") };
use std::collections::HashMap;
let config = ShortcutSourceConfig {
api_token_env: "SHORTCUT_TOKEN_WT".to_string(), workspace_id: "myco".to_string(),
field_mappings: crate::classify::sources::ShortcutFieldMappings {
story_type: {
let mut m = HashMap::new();
m.insert("bug".to_string(), "bug_fix".to_string());
m
},
labels: HashMap::new(),
workflow_state: HashMap::new(),
},
};
let client = reqwest::Client::new();
let story = fetch_story(&client, &config, 55, Some(&server.uri()))
.await
.expect("fetch should succeed");
let signal = classify_story(&story, &config).expect("should classify");
assert_eq!(signal.category, "bug_fix");
assert!(signal.source.contains("story_type"));
unsafe { std::env::remove_var("SHORTCUT_TOKEN_WT") };
}
}