use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Issue {
pub id: String,
pub key: String,
#[serde(rename = "self")]
pub url: Option<String>,
pub fields: IssueFields,
}
impl Issue {
pub fn summary(&self) -> &str {
&self.fields.summary
}
pub fn status(&self) -> &str {
&self.fields.status.name
}
pub fn assignee(&self) -> &str {
self.fields
.assignee
.as_ref()
.map(|a| a.display_name.as_str())
.unwrap_or("-")
}
pub fn priority(&self) -> &str {
self.fields
.priority
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or("-")
}
pub fn issue_type(&self) -> &str {
&self.fields.issuetype.name
}
pub fn description_text(&self) -> String {
match &self.fields.description {
Some(doc) => extract_adf_text(doc),
None => String::new(),
}
}
pub fn browser_url(&self, site_url: &str) -> String {
format!("{site_url}/browse/{}", self.key)
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct IssueFields {
pub summary: String,
pub status: StatusField,
pub assignee: Option<UserField>,
pub reporter: Option<UserField>,
pub priority: Option<PriorityField>,
pub issuetype: IssueTypeField,
pub description: Option<serde_json::Value>,
pub labels: Option<Vec<String>>,
pub created: Option<String>,
pub updated: Option<String>,
pub comment: Option<CommentList>,
#[serde(rename = "issuelinks")]
pub issue_links: Option<Vec<IssueLink>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct StatusField {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UserField {
pub display_name: String,
pub email_address: Option<String>,
#[serde(alias = "name")]
pub account_id: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PriorityField {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct IssueTypeField {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CommentList {
pub comments: Vec<Comment>,
pub total: usize,
#[serde(default)]
pub start_at: usize,
#[serde(default)]
pub max_results: usize,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
pub id: String,
pub author: UserField,
pub body: Option<serde_json::Value>,
pub created: String,
pub updated: Option<String>,
}
impl Comment {
pub fn body_text(&self) -> String {
match &self.body {
Some(doc) => extract_adf_text(doc),
None => String::new(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct User {
#[serde(alias = "name")]
pub account_id: String,
pub display_name: String,
pub email_address: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct IssueLink {
pub id: String,
#[serde(rename = "type")]
pub link_type: IssueLinkType,
pub outward_issue: Option<LinkedIssue>,
pub inward_issue: Option<LinkedIssue>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct IssueLinkType {
pub id: String,
pub name: String,
pub inward: String,
pub outward: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LinkedIssue {
pub key: String,
pub fields: LinkedIssueFields,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LinkedIssueFields {
pub summary: String,
pub status: StatusField,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Board {
pub id: u64,
pub name: String,
#[serde(rename = "type")]
pub board_type: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoardSearchResponse {
pub values: Vec<Board>,
pub is_last: bool,
#[serde(default)]
pub start_at: usize,
pub total: usize,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Sprint {
pub id: u64,
pub name: String,
pub state: String,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub complete_date: Option<String>,
pub origin_board_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintSearchResponse {
pub values: Vec<Sprint>,
pub is_last: bool,
#[serde(default)]
pub start_at: usize,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Field {
pub id: String,
pub name: String,
#[serde(default)]
pub custom: bool,
pub schema: Option<FieldSchema>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct FieldSchema {
#[serde(rename = "type")]
pub field_type: String,
pub items: Option<String>,
pub system: Option<String>,
pub custom: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Project {
pub id: String,
pub key: String,
pub name: String,
#[serde(rename = "projectTypeKey")]
pub project_type: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectSearchResponse {
pub values: Vec<Project>,
pub total: usize,
#[serde(default)]
pub start_at: usize,
#[serde(default)]
pub max_results: usize,
pub is_last: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Transition {
pub id: String,
pub name: String,
pub to: Option<TransitionTo>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TransitionTo {
pub name: String,
pub status_category: Option<StatusCategory>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct StatusCategory {
pub key: String,
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchJqlPage {
pub issues: Vec<Issue>,
#[serde(default)]
pub is_last: bool,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchJqlSkipPage {
#[serde(default)]
pub issues: Vec<serde_json::Value>,
#[serde(default)]
pub is_last: bool,
#[serde(default)]
pub next_page_token: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SearchResponse {
pub issues: Vec<Issue>,
pub total: Option<usize>,
#[serde(rename = "startAt")]
pub start_at: usize,
#[serde(rename = "maxResults")]
pub max_results: usize,
#[serde(rename = "isLast", default)]
pub is_last: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct TransitionsResponse {
pub transitions: Vec<Transition>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorklogEntry {
pub id: String,
pub author: UserField,
pub time_spent: String,
pub time_spent_seconds: u64,
pub started: String,
pub created: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateIssueResponse {
pub id: String,
pub key: String,
#[serde(rename = "self")]
pub url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Myself {
#[serde(alias = "name")]
pub account_id: String,
pub display_name: String,
}
pub fn text_to_adf(text: &str) -> serde_json::Value {
let paragraphs: Vec<serde_json::Value> = text
.split('\n')
.map(|line| {
if line.is_empty() {
serde_json::json!({ "type": "paragraph", "content": [] })
} else {
serde_json::json!({
"type": "paragraph",
"content": [{"type": "text", "text": line}]
})
}
})
.collect();
serde_json::json!({
"type": "doc",
"version": 1,
"content": paragraphs
})
}
pub fn extract_adf_text(node: &serde_json::Value) -> String {
if let Some(s) = node.as_str() {
return s.to_string();
}
let mut buf = String::new();
collect_text(node, &mut buf);
buf.trim().to_string()
}
fn collect_text(node: &serde_json::Value, buf: &mut String) {
let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
if node_type == "text" {
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
buf.push_str(text);
}
return;
}
if node_type == "hardBreak" {
buf.push('\n');
return;
}
if let Some(content) = node.get("content").and_then(|v| v.as_array()) {
for child in content {
collect_text(child, buf);
}
}
if matches!(
node_type,
"paragraph"
| "heading"
| "bulletList"
| "orderedList"
| "listItem"
| "codeBlock"
| "blockquote"
| "rule"
) && !buf.ends_with('\n')
{
buf.push('\n');
}
}
pub fn escape_jql(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_simple_paragraph() {
let doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
});
assert_eq!(extract_adf_text(&doc), "Hello world");
}
#[test]
fn extract_multiple_paragraphs() {
let doc = serde_json::json!({
"type": "doc",
"version": 1,
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "First"}]},
{"type": "paragraph", "content": [{"type": "text", "text": "Second"}]}
]
});
let text = extract_adf_text(&doc);
assert!(text.contains("First"));
assert!(text.contains("Second"));
}
#[test]
fn text_to_adf_preserves_newlines() {
let original = "Line one\nLine two\nLine three";
let adf = text_to_adf(original);
let extracted = extract_adf_text(&adf);
assert!(extracted.contains("Line one"));
assert!(extracted.contains("Line two"));
assert!(extracted.contains("Line three"));
}
#[test]
fn text_to_adf_single_line_roundtrip() {
let original = "My description text";
let adf = text_to_adf(original);
let extracted = extract_adf_text(&adf);
assert_eq!(extracted, original);
}
#[test]
fn text_to_adf_blank_line_produces_empty_paragraph() {
let adf = text_to_adf("First\n\nThird");
let content = adf["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
let blank_paragraph = &content[1];
assert_eq!(blank_paragraph["type"], "paragraph");
let blank_content = blank_paragraph["content"].as_array().unwrap();
assert!(blank_content.is_empty());
}
#[test]
fn escape_jql_double_quotes() {
assert_eq!(escape_jql(r#"say "hello""#), r#"say \"hello\""#);
}
#[test]
fn escape_jql_clean_input() {
assert_eq!(escape_jql("In Progress"), "In Progress");
}
#[test]
fn escape_jql_backslash() {
assert_eq!(escape_jql(r"foo\bar"), r"foo\\bar");
}
}