use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value;
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JiraTransition {
pub from_status: Option<String>,
pub to_status: String,
pub author: Option<String>,
pub created: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangelogWalk {
pub issues: Vec<ChangelogIssue>,
pub offset_paged_minute: Option<DateTime<Utc>>,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ChangelogIssue {
pub key: String,
pub project_key: String,
pub updated: Option<DateTime<Utc>>,
pub transitions: Vec<JiraTransition>,
pub truncated_history_total: Option<u64>,
}
impl ChangelogIssue {
pub(crate) fn from_api(api: ChangelogApiIssue) -> Self {
let truncated_history_total = if embedded_changelog_is_truncated(&api) {
api.changelog.as_ref().and_then(|cl| cl.total)
} else {
None
};
let project_key = api
.fields
.project
.map(|p| p.key)
.unwrap_or_else(|| project_key_from_issue_key(&api.key));
let updated = api.fields.updated.as_deref().and_then(parse_jira_datetime);
let transitions = match api.changelog {
Some(cl) => transitions_from_histories(&api.key, cl.histories),
None => Vec::new(),
};
Self {
key: api.key,
project_key,
updated,
transitions,
truncated_history_total,
}
}
}
pub(crate) fn transitions_from_histories(
key: &str,
histories: Vec<ApiChangelogHistory>,
) -> Vec<JiraTransition> {
let mut transitions = Vec::new();
for history in histories {
let author = history.author.map(|a| a.display_name);
let Some(created) = parse_jira_datetime(&history.created) else {
warn!(
key = %key,
created = %history.created,
"unparseable changelog history timestamp; skipping this history entry"
);
continue;
};
for item in history.items {
if item.field != "status" {
continue;
}
let Some(to_status) = item.to_string else {
continue;
};
transitions.push(JiraTransition {
from_status: item.from_string,
to_status,
author: author.clone(),
created,
});
}
}
transitions.sort_by_key(|t| t.created);
transitions
}
pub(crate) fn embedded_changelog_is_truncated(issue: &ChangelogApiIssue) -> bool {
let Some(cl) = issue.changelog.as_ref() else {
return false;
};
match cl.total {
Some(total) => total > cl.histories.len() as u64,
None => {
if !cl.histories.is_empty() {
warn!(
key = %issue.key,
embedded = cl.histories.len(),
"JIRA omitted changelog.total; embedded-changelog completeness \
cannot be verified for this ticket"
);
}
false
}
}
}
pub(crate) fn project_key_from_issue_key(key: &str) -> String {
key.rsplit_once('-')
.map(|(prefix, _)| prefix.to_string())
.unwrap_or_else(|| key.to_string())
}
pub(crate) fn parse_jira_datetime(s: &str) -> Option<DateTime<Utc>> {
if let Ok(d) = DateTime::parse_from_rfc3339(s) {
return Some(d.with_timezone(&Utc));
}
chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.3f%z")
.ok()
.map(|d| d.with_timezone(&Utc))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JiraComment {
pub id: String,
pub author: Option<String>,
pub created: DateTime<Utc>,
pub body_len: i64,
}
impl JiraComment {
pub(crate) fn from_api(api: ApiComment) -> Option<Self> {
let created = match parse_jira_datetime(&api.created) {
Some(c) => c,
None => {
warn!(
comment_id = %api.id,
created = %api.created,
"unparseable comment timestamp; skipping this comment"
);
return None;
}
};
let body_len = match &api.body {
Value::String(s) => s.len() as i64,
other => serde_json::to_string(other).map(|s| s.len()).unwrap_or(0) as i64,
};
Some(Self {
id: api.id,
author: api.author.map(|a| a.display_name),
created,
body_len,
})
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChangelogSearchResponse {
pub(crate) issues: Vec<ChangelogApiIssue>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChangelogApiIssue {
pub(crate) key: String,
pub(crate) fields: ChangelogApiFields,
#[serde(default)]
pub(crate) changelog: Option<ApiChangelog>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct ChangelogApiFields {
#[serde(default)]
pub(crate) project: Option<ApiProjectRef>,
#[serde(default)]
pub(crate) updated: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiProjectRef {
pub(crate) key: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiChangelog {
pub(crate) histories: Vec<ApiChangelogHistory>,
#[serde(default)]
pub(crate) total: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChangelogPageResponse {
#[serde(default)]
pub(crate) values: Vec<ApiChangelogHistory>,
#[serde(default)]
pub(crate) total: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiChangelogHistory {
#[serde(default)]
pub(crate) author: Option<ApiAuthor>,
pub(crate) created: String,
pub(crate) items: Vec<ApiChangelogItem>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiAuthor {
#[serde(rename = "displayName")]
pub(crate) display_name: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiChangelogItem {
pub(crate) field: String,
#[serde(rename = "fromString", default)]
pub(crate) from_string: Option<String>,
#[serde(rename = "toString", default)]
pub(crate) to_string: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct CommentSearchResponse {
pub(crate) comments: Vec<ApiComment>,
#[serde(rename = "maxResults", default)]
pub(crate) max_results: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiComment {
pub(crate) id: String,
#[serde(default)]
pub(crate) author: Option<ApiAuthor>,
pub(crate) created: String,
#[serde(default = "default_comment_body")]
pub(crate) body: Value,
}
pub(crate) fn default_comment_body() -> Value {
Value::String(String::new())
}