use std::sync::LazyLock;
use regex::Regex;
use serde::Deserialize;
use super::{
ContextSection, ContextSnippet, ContextSourceError, SNIPPET_BODY_CHARS,
truncate_on_char_boundary,
};
const SOURCE_NAME: &str = "jira";
static JIRA_TICKET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b([A-Z][A-Z0-9]+-\d+)\b").expect("JIRA ticket regex is a valid literal")
});
pub fn extract_ticket_ids(text: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for cap in JIRA_TICKET_RE.captures_iter(text) {
let key = cap[1].to_string();
if !seen.contains(&key) {
seen.push(key);
}
}
seen
}
pub fn render_description_text(value: &serde_json::Value) -> String {
fn walk(node: &serde_json::Value, out: &mut String) {
match node {
serde_json::Value::Object(map) => {
if map.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(text) = map.get("text").and_then(|t| t.as_str())
{
out.push_str(text);
}
if let Some(serde_json::Value::Array(children)) = map.get("content") {
for child in children {
walk(child, out);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
walk(item, out);
}
}
_ => {}
}
}
let rendered = match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.clone(),
other => {
let mut acc = String::new();
walk(other, &mut acc);
acc
}
};
truncate_on_char_boundary(rendered.trim(), SNIPPET_BODY_CHARS).to_string()
}
#[derive(Debug, Deserialize)]
pub struct JiraSearchResponse {
#[serde(default)]
issues: Vec<JiraIssue>,
}
#[derive(Debug, Deserialize)]
struct JiraIssue {
key: String,
#[serde(default)]
fields: JiraFields,
}
#[derive(Debug, Default, Deserialize)]
struct JiraFields {
#[serde(default)]
summary: Option<String>,
#[serde(default)]
status: Option<JiraStatus>,
#[serde(default)]
description: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct JiraStatus {
name: String,
}
pub fn parse_section(body: &str, base_url: &str) -> Result<ContextSection, ContextSourceError> {
let resp: JiraSearchResponse =
serde_json::from_str(body).map_err(|e| ContextSourceError::Parse {
src: SOURCE_NAME,
detail: e.to_string(),
})?;
let snippets = resp
.issues
.into_iter()
.map(|issue| {
let summary = issue.fields.summary.unwrap_or_default();
let title = if summary.is_empty() {
issue.key.clone()
} else {
format!("{} — {summary}", issue.key)
};
let description = render_description_text(&issue.fields.description);
ContextSnippet {
title,
subtitle: issue.fields.status.map(|s| s.name),
body: (!description.is_empty()).then_some(description),
link: Some(format!("{base_url}/browse/{}", issue.key)),
}
})
.collect();
Ok(ContextSection {
heading: "Related JIRA tickets".to_string(),
snippets,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_ticket_ids_single() {
assert_eq!(extract_ticket_ids("Implements PROJ-123"), vec!["PROJ-123"]);
}
#[test]
fn extract_ticket_ids_multiple_dedup() {
let ids = extract_ticket_ids("PROJ-1 fixes ABC-99 and PROJ-1 again, plus IC-7");
assert_eq!(ids, vec!["PROJ-1", "ABC-99", "IC-7"]);
}
#[test]
fn extract_ticket_ids_none() {
assert!(extract_ticket_ids("no tickets here, lower-case-1 ignored").is_empty());
assert!(extract_ticket_ids("abc-12 and 12-34").is_empty());
}
#[test]
fn extract_ticket_ids_from_title_and_body() {
let ids = extract_ticket_ids("Add auth (PROJ-5)\nCloses PROJ-6, refs OPS-100");
assert_eq!(ids, vec!["PROJ-5", "PROJ-6", "OPS-100"]);
}
#[test]
fn render_description_plain() {
let v = serde_json::json!("just a plain description");
assert_eq!(render_description_text(&v), "just a plain description");
}
#[test]
fn render_description_null() {
assert_eq!(render_description_text(&serde_json::Value::Null), "");
}
#[test]
fn render_description_adf() {
let v = serde_json::json!({
"type": "doc",
"content": [
{"type": "paragraph", "content": [
{"type": "text", "text": "Refresh "},
{"type": "text", "text": "tokens before expiry."}
]}
]
});
assert_eq!(render_description_text(&v), "Refresh tokens before expiry.");
}
#[test]
fn render_description_truncates() {
let long = "x".repeat(SNIPPET_BODY_CHARS + 100);
let v = serde_json::json!(long);
assert_eq!(
render_description_text(&v).chars().count(),
SNIPPET_BODY_CHARS
);
}
#[test]
fn parse_issues_to_section() {
let body = r#"{
"issues": [
{"key": "PROJ-1", "fields": {"summary": "Add auth", "status": {"name": "In Progress"}}},
{"key": "PROJ-2", "fields": {"summary": "Refresh tokens", "status": {"name": "Done"}}}
]
}"#;
let section = parse_section(body, "https://acme.atlassian.net").unwrap();
assert_eq!(section.heading, "Related JIRA tickets");
assert_eq!(section.snippets.len(), 2);
assert_eq!(section.snippets[0].title, "PROJ-1 — Add auth");
assert_eq!(section.snippets[0].subtitle.as_deref(), Some("In Progress"));
assert_eq!(
section.snippets[0].link.as_deref(),
Some("https://acme.atlassian.net/browse/PROJ-1")
);
assert!(section.snippets[0].body.is_none());
}
#[test]
fn parse_embeds_description_body() {
let body = r#"{
"issues": [
{"key": "PROJ-9", "fields": {
"summary": "Auth",
"status": {"name": "Open"},
"description": {"type":"doc","content":[
{"type":"paragraph","content":[
{"type":"text","text":"User can refresh a token."}
]}
]}
}}
]
}"#;
let section = parse_section(body, "https://acme.atlassian.net").unwrap();
assert_eq!(
section.snippets[0].body.as_deref(),
Some("User can refresh a token.")
);
}
#[test]
fn parse_embeds_plain_description_body() {
let body = r#"{"issues":[{"key":"X-1","fields":{"summary":"s","description":"plain text desc"}}]}"#;
let section = parse_section(body, "https://acme.atlassian.net").unwrap();
assert_eq!(section.snippets[0].body.as_deref(), Some("plain text desc"));
}
#[test]
fn parse_handles_missing_fields() {
let body = r#"{"issues":[{"key":"X-9","fields":{}}]}"#;
let section = parse_section(body, "https://acme.atlassian.net").unwrap();
assert_eq!(section.snippets[0].title, "X-9");
assert!(section.snippets[0].subtitle.is_none());
assert!(section.snippets[0].body.is_none());
}
#[test]
fn parse_error_on_garbage() {
let r = parse_section("not json", "https://acme.atlassian.net");
assert!(matches!(r, Err(ContextSourceError::Parse { .. })));
}
}