use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context, Result};
use base64::Engine;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::convert::adf_to_markdown;
use crate::atlassian::error::AtlassianError;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const PAGE_SIZE: u32 = 100;
const MAX_RETRIES: u32 = 3;
const DEFAULT_RETRY_DELAY_SECS: u64 = 2;
pub struct AtlassianClient {
client: Client,
instance_url: String,
auth_header: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraIssue {
pub key: String,
pub summary: String,
pub description_adf: Option<serde_json::Value>,
pub status: Option<String>,
pub issue_type: Option<String>,
pub assignee: Option<String>,
pub priority: Option<String>,
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom_fields: Vec<JiraCustomField>,
}
#[derive(Debug, Clone, Default)]
pub enum FieldSelection {
#[default]
Standard,
Named(Vec<String>),
All,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraCustomField {
pub id: String,
pub name: String,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Default)]
pub struct EditMeta {
pub fields: std::collections::BTreeMap<String, EditMetaField>,
}
#[derive(Debug, Clone)]
pub struct EditMetaField {
pub name: String,
pub schema: EditMetaSchema,
}
#[derive(Debug, Clone)]
pub struct EditMetaSchema {
pub kind: String,
pub custom: Option<String>,
}
impl EditMetaField {
pub fn is_adf_rich_text(&self) -> bool {
matches!(
self.schema.custom.as_deref(),
Some("com.atlassian.jira.plugin.system.customfieldtypes:textarea")
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraUser {
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "emailAddress")]
pub email_address: Option<String>,
#[serde(rename = "accountId")]
pub account_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraWatcherList {
pub watchers: Vec<JiraUser>,
pub watch_count: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraCreatedIssue {
pub key: String,
pub id: String,
pub self_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraSearchResult {
pub issues: Vec<JiraIssue>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceSearchResult {
pub id: String,
pub title: String,
pub space_key: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceSearchResults {
pub results: Vec<ConfluenceSearchResult>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceUserSearchResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceUserSearchResults {
pub users: Vec<ConfluenceUserSearchResult>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraComment {
pub id: String,
pub author: String,
pub body_adf: Option<serde_json::Value>,
pub created: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraProject {
pub id: String,
pub key: String,
pub name: String,
pub project_type: Option<String>,
pub lead: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraProjectList {
pub projects: Vec<JiraProject>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraField {
pub id: String,
pub name: String,
pub custom: bool,
pub schema_type: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraFieldOption {
pub id: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgileBoard {
pub id: u64,
pub name: String,
pub board_type: String,
pub project_key: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgileBoardList {
pub boards: Vec<AgileBoard>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgileSprint {
pub id: u64,
pub name: String,
pub state: String,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub goal: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgileSprintList {
pub sprints: Vec<AgileSprint>,
pub total: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraChangelogEntry {
pub id: String,
pub author: String,
pub created: String,
pub items: Vec<JiraChangelogItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraChangelogItem {
pub field: String,
pub from_string: Option<String>,
pub to_string: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraLinkType {
pub id: String,
pub name: String,
pub inward: String,
pub outward: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraIssueLink {
pub id: String,
pub link_type: String,
pub direction: String,
pub linked_issue_key: String,
pub linked_issue_summary: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraAttachment {
pub id: String,
pub filename: String,
pub mime_type: String,
pub size: u64,
pub content_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraTransition {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevPullRequest {
pub id: String,
pub name: String,
pub status: String,
pub url: String,
pub repository_name: String,
pub source_branch: String,
pub destination_branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub reviewers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_update: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevCommit {
pub id: String,
pub display_id: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
pub url: String,
pub file_count: u32,
pub merge: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevBranch {
pub name: String,
pub url: String,
pub repository_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_pr_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_commit: Option<JiraDevCommit>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevRepository {
pub name: String,
pub url: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub commits: Vec<JiraDevCommit>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevStatus {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pull_requests: Vec<JiraDevPullRequest>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub branches: Vec<JiraDevBranch>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<JiraDevRepository>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevStatusCount {
pub count: u32,
pub providers: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraDevStatusSummary {
pub pullrequest: JiraDevStatusCount,
pub branch: JiraDevStatusCount,
pub repository: JiraDevStatusCount,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraWorklog {
pub id: String,
pub author: String,
pub time_spent: String,
pub time_spent_seconds: u64,
pub started: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JiraWorklogList {
pub worklogs: Vec<JiraWorklog>,
pub total: u32,
}
#[derive(Deserialize)]
struct JiraIssueResponse {
key: String,
fields: JiraIssueFields,
}
#[derive(Deserialize)]
struct JiraIssueEnvelope {
key: String,
#[serde(default)]
fields: std::collections::BTreeMap<String, serde_json::Value>,
#[serde(default)]
names: std::collections::BTreeMap<String, String>,
}
impl JiraIssueEnvelope {
fn into_issue(self, selection: &FieldSelection) -> JiraIssue {
let Self {
key,
mut fields,
names,
} = self;
let description_adf = fields.remove("description").filter(|v| !v.is_null());
let summary = fields
.remove("summary")
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
let status = extract_named_field(fields.remove("status"));
let issue_type = extract_named_field(fields.remove("issuetype"));
let assignee = extract_display_name(fields.remove("assignee"));
let priority = extract_named_field(fields.remove("priority"));
let labels = fields
.remove("labels")
.and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
.unwrap_or_default();
let collect_customs = !matches!(selection, FieldSelection::Standard);
let custom_fields = if collect_customs {
fields
.into_iter()
.filter(|(_, value)| !value.is_null())
.map(|(id, value)| {
let name = names.get(&id).cloned().unwrap_or_else(|| id.clone());
JiraCustomField { id, name, value }
})
.collect()
} else {
Vec::new()
};
JiraIssue {
key,
summary,
description_adf,
status,
issue_type,
assignee,
priority,
labels,
custom_fields,
}
}
}
fn extract_named_field(value: Option<serde_json::Value>) -> Option<String> {
value
.and_then(|v| v.get("name").cloned())
.and_then(|n| n.as_str().map(str::to_string))
}
fn extract_display_name(value: Option<serde_json::Value>) -> Option<String> {
value
.and_then(|v| v.get("displayName").cloned())
.and_then(|n| n.as_str().map(str::to_string))
}
#[derive(Deserialize)]
struct JiraEditMetaResponse {
#[serde(default)]
fields: std::collections::BTreeMap<String, JiraEditMetaField>,
}
#[derive(Deserialize)]
struct JiraEditMetaField {
#[serde(default)]
name: Option<String>,
#[serde(default)]
schema: Option<JiraEditMetaSchemaRaw>,
}
#[derive(Deserialize)]
struct JiraEditMetaSchemaRaw {
#[serde(rename = "type", default)]
kind: Option<String>,
#[serde(default)]
custom: Option<String>,
}
#[derive(Deserialize)]
struct JiraCreateMetaResponse {
#[serde(default)]
projects: Vec<JiraCreateMetaProject>,
}
#[derive(Deserialize)]
struct JiraCreateMetaProject {
#[serde(default)]
issuetypes: Vec<JiraCreateMetaIssueType>,
}
#[derive(Deserialize)]
struct JiraCreateMetaIssueType {
#[serde(default)]
fields: std::collections::BTreeMap<String, JiraEditMetaField>,
}
#[derive(Deserialize)]
struct JiraIssueFields {
summary: Option<String>,
description: Option<serde_json::Value>,
status: Option<JiraNameField>,
issuetype: Option<JiraNameField>,
assignee: Option<JiraAssigneeField>,
priority: Option<JiraNameField>,
#[serde(default)]
labels: Vec<String>,
}
#[derive(Deserialize)]
struct JiraNameField {
name: Option<String>,
}
#[derive(Deserialize)]
struct JiraAssigneeField {
#[serde(rename = "displayName")]
display_name: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct JiraSearchResponse {
issues: Vec<JiraIssueResponse>,
#[serde(default)]
total: u32,
#[serde(rename = "nextPageToken", default)]
next_page_token: Option<String>,
}
#[derive(Deserialize)]
struct JiraTransitionsResponse {
transitions: Vec<JiraTransitionEntry>,
}
#[derive(Deserialize)]
struct JiraTransitionEntry {
id: String,
name: String,
}
#[derive(Deserialize)]
struct JiraCommentsResponse {
#[serde(default)]
comments: Vec<JiraCommentEntry>,
#[serde(default)]
total: u32,
#[serde(rename = "startAt", default)]
start_at: u32,
#[serde(rename = "maxResults", default)]
#[allow(dead_code)]
max_results: u32,
}
#[derive(Deserialize)]
struct JiraCommentEntry {
id: String,
author: Option<JiraCommentAuthor>,
body: Option<serde_json::Value>,
created: Option<String>,
}
#[derive(Deserialize)]
struct JiraCommentAuthor {
#[serde(rename = "displayName")]
display_name: Option<String>,
}
#[derive(Deserialize)]
struct JiraWorklogResponse {
#[serde(default)]
worklogs: Vec<JiraWorklogEntry>,
#[serde(default)]
total: u32,
}
#[derive(Deserialize)]
struct JiraWorklogEntry {
id: String,
author: Option<JiraCommentAuthor>,
#[serde(rename = "timeSpent")]
time_spent: Option<String>,
#[serde(rename = "timeSpentSeconds", default)]
time_spent_seconds: u64,
started: Option<String>,
comment: Option<serde_json::Value>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct ConfluenceContentSearchResponse {
results: Vec<ConfluenceContentSearchEntry>,
#[serde(default)]
size: u32,
#[serde(rename = "_links", default)]
links: Option<ConfluenceSearchLinks>,
}
#[derive(Deserialize, Default)]
struct ConfluenceSearchLinks {
next: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceContentSearchEntry {
id: String,
title: String,
#[serde(rename = "_expandable")]
expandable: Option<ConfluenceExpandable>,
}
#[derive(Deserialize)]
struct ConfluenceExpandable {
space: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceUserSearchResponse {
results: Vec<ConfluenceUserSearchEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceSearchLinks>,
}
#[derive(Deserialize)]
struct ConfluenceUserSearchEntry {
#[serde(default)]
user: Option<ConfluenceSearchUser>,
}
#[derive(Deserialize)]
struct ConfluenceSearchUser {
#[serde(rename = "accountId", default)]
account_id: Option<String>,
#[serde(rename = "displayName", default)]
display_name: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(rename = "publicName", default)]
public_name: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct AgileBoardListResponse {
values: Vec<AgileBoardEntry>,
#[serde(default)]
total: u32,
#[serde(rename = "isLast", default)]
is_last: bool,
}
#[derive(Deserialize)]
struct AgileBoardEntry {
id: u64,
name: String,
#[serde(rename = "type")]
board_type: String,
location: Option<AgileBoardLocation>,
}
#[derive(Deserialize)]
struct AgileBoardLocation {
#[serde(rename = "projectKey")]
project_key: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct AgileIssueListResponse {
issues: Vec<JiraIssueResponse>,
#[serde(default)]
total: u32,
#[serde(rename = "isLast", default)]
is_last: bool,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct AgileSprintListResponse {
values: Vec<AgileSprintEntry>,
#[serde(default)]
total: u32,
#[serde(rename = "isLast", default)]
is_last: bool,
}
#[derive(Deserialize)]
struct AgileSprintEntry {
id: u64,
name: String,
state: String,
#[serde(rename = "startDate")]
start_date: Option<String>,
#[serde(rename = "endDate")]
end_date: Option<String>,
goal: Option<String>,
}
#[derive(Deserialize)]
struct JiraIssueLinksResponse {
fields: JiraIssueLinksFields,
}
#[derive(Deserialize)]
struct JiraIssueLinksFields {
#[serde(default)]
issuelinks: Vec<JiraIssueLinkEntry>,
}
#[derive(Deserialize)]
struct JiraIssueLinkEntry {
id: String,
#[serde(rename = "type")]
link_type: JiraIssueLinkType,
#[serde(rename = "inwardIssue")]
inward_issue: Option<JiraIssueLinkIssue>,
#[serde(rename = "outwardIssue")]
outward_issue: Option<JiraIssueLinkIssue>,
}
#[derive(Deserialize)]
struct JiraIssueLinkType {
name: String,
}
#[derive(Deserialize)]
struct JiraIssueLinkIssue {
key: String,
fields: Option<JiraIssueLinkIssueFields>,
}
#[derive(Deserialize)]
struct JiraIssueLinkIssueFields {
summary: Option<String>,
}
#[derive(Deserialize)]
struct JiraLinkTypesResponse {
#[serde(rename = "issueLinkTypes")]
issue_link_types: Vec<JiraLinkTypeEntry>,
}
#[derive(Deserialize)]
struct JiraLinkTypeEntry {
id: String,
name: String,
inward: String,
outward: String,
}
#[derive(Deserialize)]
struct JiraAttachmentIssueResponse {
fields: JiraAttachmentFields,
}
#[derive(Deserialize)]
struct JiraAttachmentFields {
#[serde(default)]
attachment: Vec<JiraAttachmentEntry>,
}
#[derive(Deserialize)]
struct JiraAttachmentEntry {
id: String,
filename: String,
#[serde(rename = "mimeType")]
mime_type: String,
size: u64,
content: String,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct JiraChangelogResponse {
values: Vec<JiraChangelogEntryResponse>,
#[serde(default)]
total: u32,
#[serde(rename = "isLast", default)]
is_last: bool,
}
#[derive(Deserialize)]
struct JiraChangelogEntryResponse {
id: String,
author: Option<JiraCommentAuthor>,
created: Option<String>,
#[serde(default)]
items: Vec<JiraChangelogItemResponse>,
}
#[derive(Deserialize)]
struct JiraChangelogItemResponse {
field: String,
#[serde(rename = "fromString")]
from_string: Option<String>,
#[serde(rename = "toString")]
to_string: Option<String>,
}
#[derive(Deserialize)]
struct JiraFieldEntry {
id: String,
name: String,
#[serde(default)]
custom: bool,
schema: Option<JiraFieldSchema>,
}
#[derive(Deserialize)]
struct JiraFieldSchema {
#[serde(rename = "type")]
schema_type: Option<String>,
}
#[derive(Deserialize)]
struct JiraFieldContextsResponse {
values: Vec<JiraFieldContextEntry>,
}
#[derive(Deserialize)]
struct JiraFieldContextEntry {
id: String,
}
#[derive(Deserialize)]
struct JiraFieldOptionsResponse {
values: Vec<JiraFieldOptionEntry>,
}
#[derive(Deserialize)]
struct JiraFieldOptionEntry {
id: String,
value: String,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct JiraProjectSearchResponse {
values: Vec<JiraProjectEntry>,
total: u32,
#[serde(rename = "isLast", default)]
is_last: bool,
}
#[derive(Deserialize)]
struct JiraProjectEntry {
id: String,
key: String,
name: String,
#[serde(rename = "projectTypeKey")]
project_type_key: Option<String>,
lead: Option<JiraProjectLead>,
}
#[derive(Deserialize)]
struct JiraProjectLead {
#[serde(rename = "displayName")]
display_name: Option<String>,
}
#[derive(Deserialize)]
struct JiraCreateResponse {
key: String,
id: String,
#[serde(rename = "self")]
self_url: String,
}
#[derive(Deserialize)]
struct JiraIssueIdResponse {
id: String,
}
#[derive(Deserialize)]
struct DevStatusResponse {
#[serde(default)]
detail: Vec<DevStatusDetail>,
}
#[derive(Deserialize)]
struct DevStatusDetail {
#[serde(rename = "pullRequests", default)]
pull_requests: Vec<DevStatusPullRequest>,
#[serde(default)]
branches: Vec<DevStatusBranch>,
#[serde(default)]
repositories: Vec<DevStatusRepositoryEntry>,
}
#[derive(Deserialize)]
struct DevStatusPullRequest {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
status: String,
#[serde(default)]
url: String,
#[serde(rename = "repositoryName", default)]
repository_name: String,
#[serde(default)]
source: Option<DevStatusBranchRef>,
#[serde(default)]
destination: Option<DevStatusBranchRef>,
#[serde(default)]
author: Option<DevStatusAuthor>,
#[serde(default)]
reviewers: Vec<DevStatusReviewer>,
#[serde(rename = "commentCount", default)]
comment_count: Option<u32>,
#[serde(rename = "lastUpdate", default)]
last_update: Option<String>,
}
#[derive(Deserialize)]
struct DevStatusBranchRef {
#[serde(default)]
branch: String,
}
#[derive(Deserialize)]
struct DevStatusAuthor {
#[serde(default)]
name: String,
}
#[derive(Deserialize)]
struct DevStatusReviewer {
#[serde(default)]
name: String,
}
#[derive(Deserialize)]
struct DevStatusCommit {
#[serde(default)]
id: String,
#[serde(rename = "displayId", default)]
display_id: String,
#[serde(default)]
message: String,
#[serde(default)]
author: Option<DevStatusAuthor>,
#[serde(rename = "authorTimestamp", default)]
author_timestamp: Option<String>,
#[serde(default)]
url: String,
#[serde(rename = "fileCount", default)]
file_count: u32,
#[serde(default)]
merge: bool,
}
#[derive(Deserialize)]
struct DevStatusBranch {
#[serde(default)]
name: String,
#[serde(default)]
url: String,
#[serde(rename = "repositoryName", default)]
repository_name: String,
#[serde(rename = "createPullRequestUrl", default)]
create_pr_url: Option<String>,
#[serde(rename = "lastCommit", default)]
last_commit: Option<DevStatusCommit>,
}
#[derive(Deserialize)]
struct DevStatusRepositoryEntry {
#[serde(default)]
name: String,
#[serde(default)]
url: String,
#[serde(default)]
commits: Vec<DevStatusCommit>,
}
#[derive(Deserialize)]
struct DevStatusSummaryResponse {
#[serde(default)]
summary: DevStatusSummaryData,
}
#[derive(Deserialize, Default)]
struct DevStatusSummaryData {
#[serde(default)]
pullrequest: Option<DevStatusSummaryCategory>,
#[serde(default)]
branch: Option<DevStatusSummaryCategory>,
#[serde(default)]
repository: Option<DevStatusSummaryCategory>,
}
#[derive(Deserialize)]
struct DevStatusSummaryCategory {
overall: Option<DevStatusSummaryOverall>,
#[serde(rename = "byInstanceType", default)]
by_instance_type: HashMap<String, DevStatusSummaryInstance>,
}
#[derive(Deserialize)]
struct DevStatusSummaryOverall {
#[serde(default)]
count: u32,
}
#[derive(Deserialize)]
struct DevStatusSummaryInstance {
#[serde(default)]
name: String,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn new_client_strips_trailing_slash() {
let client =
AtlassianClient::new("https://org.atlassian.net/", "user@test.com", "token").unwrap();
assert_eq!(client.instance_url(), "https://org.atlassian.net");
}
#[test]
fn new_client_preserves_clean_url() {
let client =
AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
assert_eq!(client.instance_url(), "https://org.atlassian.net");
}
#[test]
fn new_client_sets_basic_auth() {
let client =
AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
let expected_credentials = "user@test.com:token";
let expected_encoded =
base64::engine::general_purpose::STANDARD.encode(expected_credentials);
assert_eq!(client.auth_header, format!("Basic {expected_encoded}"));
}
#[test]
fn from_credentials() {
let creds = crate::atlassian::auth::AtlassianCredentials {
instance_url: "https://org.atlassian.net".to_string(),
email: "user@test.com".to_string(),
api_token: "token123".to_string(),
};
let client = AtlassianClient::from_credentials(&creds).unwrap();
assert_eq!(client.instance_url(), "https://org.atlassian.net");
}
#[test]
fn jira_issue_struct_fields() {
let issue = JiraIssue {
key: "TEST-1".to_string(),
summary: "Test issue".to_string(),
description_adf: None,
status: Some("Open".to_string()),
issue_type: Some("Bug".to_string()),
assignee: Some("Alice".to_string()),
priority: Some("High".to_string()),
labels: vec!["backend".to_string()],
custom_fields: Vec::new(),
};
assert_eq!(issue.key, "TEST-1");
assert_eq!(issue.labels.len(), 1);
}
#[test]
fn jira_user_deserialization() {
let json = r#"{
"displayName": "Alice Smith",
"emailAddress": "alice@example.com",
"accountId": "abc123"
}"#;
let user: JiraUser = serde_json::from_str(json).unwrap();
assert_eq!(user.display_name, "Alice Smith");
assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
assert_eq!(user.account_id, "abc123");
}
#[test]
fn jira_user_optional_email() {
let json = r#"{
"displayName": "Bot",
"accountId": "bot123"
}"#;
let user: JiraUser = serde_json::from_str(json).unwrap();
assert!(user.email_address.is_none());
}
#[test]
fn jira_issue_response_deserialization() {
let json = r#"{
"key": "PROJ-42",
"fields": {
"summary": "Test",
"description": null,
"status": {"name": "Open"},
"issuetype": {"name": "Bug"},
"assignee": {"displayName": "Bob"},
"priority": {"name": "Medium"},
"labels": ["frontend"]
}
}"#;
let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.key, "PROJ-42");
assert_eq!(response.fields.summary.as_deref(), Some("Test"));
assert_eq!(response.fields.labels, vec!["frontend"]);
}
#[test]
fn jira_issue_response_minimal_fields() {
let json = r#"{
"key": "PROJ-1",
"fields": {
"summary": null,
"description": null,
"status": null,
"issuetype": null,
"assignee": null,
"priority": null,
"labels": []
}
}"#;
let response: JiraIssueResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.key, "PROJ-1");
assert!(response.fields.summary.is_none());
}
#[tokio::test]
async fn get_json_retries_on_429() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/test"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
)
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.get_json(&format!("{}/test", server.uri()))
.await
.unwrap();
assert!(resp.status().is_success());
}
#[tokio::test]
async fn get_json_returns_429_after_max_retries() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.get_json(&format!("{}/test", server.uri()))
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 429);
}
#[tokio::test]
async fn post_json_retries_on_429() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(201))
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let body = serde_json::json!({"key": "value"});
let resp = client
.post_json(&format!("{}/test", server.uri()), &body)
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
}
#[tokio::test]
async fn delete_retries_on_429() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/test"))
.respond_with(wiremock::ResponseTemplate::new(204))
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.delete(&format!("{}/test", server.uri()))
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 204);
}
#[tokio::test]
async fn get_json_sends_auth_header() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::header(
"Authorization",
"Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
))
.and(wiremock::matchers::header("Accept", "application/json"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.get_json(&format!("{}/test", server.uri()))
.await
.unwrap();
assert!(resp.status().is_success());
}
#[tokio::test]
async fn put_json_sends_body_and_auth() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::header(
"Authorization",
"Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
))
.and(wiremock::matchers::header(
"Content-Type",
"application/json",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let body = serde_json::json!({"key": "value"});
let resp = client
.put_json(&format!("{}/test", server.uri()), &body)
.await
.unwrap();
assert!(resp.status().is_success());
}
#[tokio::test]
async fn post_json_sends_body_and_auth() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::header(
"Authorization",
"Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
))
.and(wiremock::matchers::header(
"Content-Type",
"application/json",
))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "1"})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let body = serde_json::json!({"name": "test"});
let resp = client
.post_json(&format!("{}/test", server.uri()), &body)
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
}
#[tokio::test]
async fn post_json_error_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let body = serde_json::json!({});
let resp = client
.post_json(&format!("{}/test", server.uri()), &body)
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
#[tokio::test]
async fn delete_sends_auth_header() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::header(
"Authorization",
"Basic dXNlckB0ZXN0LmNvbTp0b2tlbg==",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.delete(&format!("{}/test", server.uri()))
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 204);
}
#[tokio::test]
async fn delete_error_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let resp = client
.delete(&format!("{}/test", server.uri()))
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
}
#[tokio::test]
async fn get_issue_success() {
let server = wiremock::MockServer::start().await;
let issue_json = serde_json::json!({
"key": "PROJ-42",
"fields": {
"summary": "Fix the bug",
"description": {
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Details"}]}]
},
"status": {"name": "Open"},
"issuetype": {"name": "Bug"},
"assignee": {"displayName": "Alice"},
"priority": {"name": "High"},
"labels": ["backend", "urgent"]
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let issue = client.get_issue("PROJ-42").await.unwrap();
assert_eq!(issue.key, "PROJ-42");
assert_eq!(issue.summary, "Fix the bug");
assert_eq!(issue.status.as_deref(), Some("Open"));
assert_eq!(issue.issue_type.as_deref(), Some("Bug"));
assert_eq!(issue.assignee.as_deref(), Some("Alice"));
assert_eq!(issue.priority.as_deref(), Some("High"));
assert_eq!(issue.labels, vec!["backend", "urgent"]);
assert!(issue.description_adf.is_some());
}
#[tokio::test]
async fn get_issue_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_issue("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_issue_with_fields_named_populates_custom_fields() {
let server = wiremock::MockServer::start().await;
let issue_json = serde_json::json!({
"key": "ACCS-1",
"fields": {
"summary": "S",
"description": null,
"status": {"name": "Open"},
"issuetype": {"name": "Bug"},
"assignee": null,
"priority": null,
"labels": [],
"customfield_19300": {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC"}]}]
}
},
"names": {
"customfield_19300": "Acceptance Criteria"
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.and(wiremock::matchers::query_param("expand", "names,schema"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let issue = client
.get_issue_with_fields(
"ACCS-1",
FieldSelection::Named(vec!["customfield_19300".to_string()]),
)
.await
.unwrap();
assert_eq!(issue.key, "ACCS-1");
assert_eq!(issue.custom_fields.len(), 1);
let cf = &issue.custom_fields[0];
assert_eq!(cf.id, "customfield_19300");
assert_eq!(cf.name, "Acceptance Criteria");
assert_eq!(cf.value["type"], "doc");
}
#[tokio::test]
async fn get_issue_with_fields_standard_omits_custom_fields() {
let server = wiremock::MockServer::start().await;
let issue_json = serde_json::json!({
"key": "ACCS-1",
"fields": {
"summary": "S",
"description": null,
"status": null,
"issuetype": null,
"assignee": null,
"priority": null,
"labels": [],
"customfield_19300": {"value": "Unplanned"}
},
"names": {
"customfield_19300": "Planned / Unplanned Work"
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let issue = client
.get_issue_with_fields("ACCS-1", FieldSelection::Standard)
.await
.unwrap();
assert!(issue.custom_fields.is_empty());
}
#[tokio::test]
async fn get_issue_with_fields_all_uses_star_param() {
let server = wiremock::MockServer::start().await;
let issue_json = serde_json::json!({
"key": "ACCS-1",
"fields": {
"summary": "S",
"description": null,
"status": null,
"issuetype": null,
"assignee": null,
"priority": null,
"labels": [],
"customfield_10001": {"value": "Unplanned"},
"customfield_10002": 42
},
"names": {
"customfield_10001": "Planned / Unplanned Work",
"customfield_10002": "Story points"
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.and(wiremock::matchers::query_param("fields", "*all"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let issue = client
.get_issue_with_fields("ACCS-1", FieldSelection::All)
.await
.unwrap();
assert_eq!(issue.custom_fields.len(), 2);
let names: Vec<&str> = issue
.custom_fields
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"Planned / Unplanned Work"));
assert!(names.contains(&"Story points"));
}
#[tokio::test]
async fn get_editmeta_parses_field_schema() {
let server = wiremock::MockServer::start().await;
let editmeta_json = serde_json::json!({
"fields": {
"customfield_19300": {
"name": "Acceptance Criteria",
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
"customId": 19300
}
},
"customfield_10001": {
"name": "Planned / Unplanned Work",
"schema": {
"type": "option",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
"customId": 10001
}
}
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/ACCS-1/editmeta",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&editmeta_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let meta = client.get_editmeta("ACCS-1").await.unwrap();
assert_eq!(meta.fields.len(), 2);
let ac = meta.fields.get("customfield_19300").unwrap();
assert_eq!(ac.name, "Acceptance Criteria");
assert!(ac.is_adf_rich_text());
let opt = meta.fields.get("customfield_10001").unwrap();
assert_eq!(opt.schema.kind, "option");
assert!(!opt.is_adf_rich_text());
}
#[tokio::test]
async fn get_editmeta_api_error_surfaces_status() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/editmeta",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_editmeta("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn update_issue_with_custom_fields_merges_into_payload() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.and(wiremock::matchers::body_json(serde_json::json!({
"fields": {
"description": {"version": 1, "type": "doc", "content": []},
"summary": "New title",
"customfield_10001": {"value": "Unplanned"},
"customfield_19300": {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph"}]
}
}
})))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let mut custom = std::collections::BTreeMap::new();
custom.insert(
"customfield_10001".to_string(),
serde_json::json!({"value": "Unplanned"}),
);
custom.insert(
"customfield_19300".to_string(),
serde_json::json!({"type": "doc", "version": 1, "content": [{"type": "paragraph"}]}),
);
let result = client
.update_issue_with_custom_fields("ACCS-1", &adf, Some("New title"), &custom)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_issue_shim_sends_no_custom_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.and(wiremock::matchers::body_json(serde_json::json!({
"fields": {
"description": {"version": 1, "type": "doc", "content": []}
}
})))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
client.update_issue("ACCS-1", &adf, None).await.unwrap();
}
#[tokio::test]
async fn get_issue_with_fields_falls_back_to_id_when_names_missing() {
let server = wiremock::MockServer::start().await;
let issue_json = serde_json::json!({
"key": "ACCS-1",
"fields": {
"summary": "S",
"description": null,
"status": null,
"issuetype": null,
"assignee": null,
"priority": null,
"labels": [],
"customfield_99999": "raw"
}
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/ACCS-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&issue_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let issue = client
.get_issue_with_fields("ACCS-1", FieldSelection::All)
.await
.unwrap();
assert_eq!(issue.custom_fields.len(), 1);
assert_eq!(issue.custom_fields[0].name, "customfield_99999");
}
#[tokio::test]
async fn update_issue_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let result = client
.update_issue("PROJ-42", &adf, Some("New title"))
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_issue_without_summary() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let result = client.update_issue("PROJ-42", &adf, None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_issue_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let err = client
.update_issue("PROJ-42", &adf, None)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn search_issues_success() {
let server = wiremock::MockServer::start().await;
let search_json = serde_json::json!({
"issues": [
{
"key": "PROJ-1",
"fields": {
"summary": "First issue",
"description": null,
"status": {"name": "Open"},
"issuetype": {"name": "Bug"},
"assignee": {"displayName": "Alice"},
"priority": {"name": "High"},
"labels": []
}
},
{
"key": "PROJ-2",
"fields": {
"summary": "Second issue",
"description": null,
"status": {"name": "Done"},
"issuetype": {"name": "Task"},
"assignee": null,
"priority": null,
"labels": ["backend"]
}
}
],
"total": 2
});
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&search_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_issues("project = PROJ", 50).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.issues.len(), 2);
assert_eq!(result.issues[0].key, "PROJ-1");
assert_eq!(result.issues[0].summary, "First issue");
assert_eq!(result.issues[0].status.as_deref(), Some("Open"));
assert_eq!(result.issues[1].key, "PROJ-2");
assert!(result.issues[1].assignee.is_none());
}
#[tokio::test]
async fn search_issues_without_total() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [{
"key": "PROJ-1",
"fields": {
"summary": "Test",
"description": null,
"status": null,
"issuetype": null,
"assignee": null,
"priority": null,
"labels": []
}
}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_issues("project = PROJ", 50).await.unwrap();
assert_eq!(result.issues.len(), 1);
assert_eq!(result.total, 1);
}
#[tokio::test]
async fn search_issues_empty_results() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"issues": [], "total": 0})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_issues("project = NOPE", 50).await.unwrap();
assert_eq!(result.total, 0);
assert!(result.issues.is_empty());
}
#[tokio::test]
async fn search_issues_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid JQL query"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.search_issues("invalid jql !!!", 50)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn create_issue_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
serde_json::json!({"key": "PROJ-124", "id": "10042", "self": "https://org.atlassian.net/rest/api/3/issue/10042"}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.create_issue("PROJ", "Bug", "Fix login", None, &[])
.await
.unwrap();
assert_eq!(result.key, "PROJ-124");
assert_eq!(result.id, "10042");
assert!(result.self_url.contains("10042"));
}
#[tokio::test]
async fn create_issue_with_description_and_labels() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
serde_json::json!({"key": "PROJ-125", "id": "10043", "self": "https://org.atlassian.net/rest/api/3/issue/10043"}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let labels = vec!["backend".to_string(), "urgent".to_string()];
let result = client
.create_issue("PROJ", "Task", "Add feature", Some(&adf), &labels)
.await
.unwrap();
assert_eq!(result.key, "PROJ-125");
}
#[tokio::test]
async fn create_issue_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Project not found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.create_issue("NOPE", "Bug", "Test", None, &[])
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn create_issue_with_custom_fields_merges_into_payload() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.and(wiremock::matchers::body_json(serde_json::json!({
"fields": {
"project": {"key": "PROJ"},
"issuetype": {"name": "Task"},
"summary": "Test",
"customfield_10001": {"value": "Unplanned"}
}
})))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100",
"key": "PROJ-100",
"self": "https://org.atlassian.net/rest/api/3/issue/100"
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let mut custom = std::collections::BTreeMap::new();
custom.insert(
"customfield_10001".to_string(),
serde_json::json!({"value": "Unplanned"}),
);
let result = client
.create_issue_with_custom_fields("PROJ", "Task", "Test", None, &[], &custom)
.await
.unwrap();
assert_eq!(result.key, "PROJ-100");
}
#[tokio::test]
async fn create_issue_shim_sends_no_custom_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.and(wiremock::matchers::body_json(serde_json::json!({
"fields": {
"project": {"key": "PROJ"},
"issuetype": {"name": "Task"},
"summary": "Test"
}
})))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100",
"key": "PROJ-100",
"self": "https://org.atlassian.net/rest/api/3/issue/100"
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
client
.create_issue("PROJ", "Task", "Test", None, &[])
.await
.unwrap();
}
#[tokio::test]
async fn get_createmeta_parses_nested_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
.and(wiremock::matchers::query_param("projectKeys", "PROJ"))
.and(wiremock::matchers::query_param("issuetypeNames", "Task"))
.and(wiremock::matchers::query_param(
"expand",
"projects.issuetypes.fields",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"projects": [{
"key": "PROJ",
"issuetypes": [{
"name": "Task",
"fields": {
"customfield_10001": {
"name": "Planned / Unplanned Work",
"schema": {
"type": "option",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
"customId": 10001
}
}
}
}]
}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
assert_eq!(meta.fields.len(), 1);
let field = meta.fields.get("customfield_10001").unwrap();
assert_eq!(field.name, "Planned / Unplanned Work");
assert_eq!(field.schema.kind, "option");
}
#[tokio::test]
async fn get_createmeta_empty_projects_returns_empty_meta() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"projects": []
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let meta = client.get_createmeta("PROJ", "Task").await.unwrap();
assert!(meta.fields.is_empty());
}
#[tokio::test]
async fn get_createmeta_api_error_surfaces_status() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/createmeta"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_createmeta("NOPE", "Task").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_comments_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 100,
"total": 2,
"comments": [
{
"id": "100",
"author": {"displayName": "Alice"},
"body": {"version": 1, "type": "doc", "content": []},
"created": "2026-04-01T10:00:00.000+0000"
},
{
"id": "101",
"author": {"displayName": "Bob"},
"body": null,
"created": "2026-04-02T14:00:00.000+0000"
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let comments = client.get_comments("PROJ-1", 0).await.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, "100");
assert_eq!(comments[0].author, "Alice");
assert!(comments[0].body_adf.is_some());
assert!(comments[0].created.contains("2026-04-01"));
assert_eq!(comments[1].id, "101");
assert_eq!(comments[1].author, "Bob");
assert!(comments[1].body_adf.is_none());
}
#[tokio::test]
async fn get_comments_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"startAt": 0, "maxResults": 100, "total": 0, "comments": []}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let comments = client.get_comments("PROJ-1", 0).await.unwrap();
assert!(comments.is_empty());
}
#[tokio::test]
async fn get_comments_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_comments("NOPE-1", 0).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_comments_paginates_with_offset() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.and(wiremock::matchers::query_param("startAt", "0"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 2,
"total": 3,
"comments": [
{"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
{"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
]
})),
)
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.and(wiremock::matchers::query_param("startAt", "2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 2,
"maxResults": 2,
"total": 3,
"comments": [
{"id": "3", "author": {"displayName": "C"}, "body": null, "created": "2026-04-03T10:00:00.000+0000"}
]
})),
)
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let comments = client.get_comments("PROJ-1", 0).await.unwrap();
assert_eq!(comments.len(), 3);
assert_eq!(comments[0].id, "1");
assert_eq!(comments[1].id, "2");
assert_eq!(comments[2].id, "3");
}
#[tokio::test]
async fn get_comments_respects_limit_single_page() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.and(wiremock::matchers::query_param("maxResults", "2"))
.and(wiremock::matchers::query_param("startAt", "0"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 2,
"total": 5,
"comments": [
{"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
{"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let comments = client.get_comments("PROJ-1", 2).await.unwrap();
assert_eq!(comments.len(), 2);
}
#[tokio::test]
async fn add_comment_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(
serde_json::json!({"id": "200", "author": {"displayName": "Me"}}),
),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let result = client.add_comment("PROJ-1", &adf).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_comment_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let adf = AdfDocument::new();
let err = client.add_comment("PROJ-1", &adf).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn get_transitions_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [
{"id": "11", "name": "In Progress"},
{"id": "21", "name": "Done"},
{"id": "31", "name": "Won't Do"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let transitions = client.get_transitions("PROJ-1").await.unwrap();
assert_eq!(transitions.len(), 3);
assert_eq!(transitions[0].id, "11");
assert_eq!(transitions[0].name, "In Progress");
assert_eq!(transitions[1].id, "21");
assert_eq!(transitions[2].name, "Won't Do");
}
#[tokio::test]
async fn get_transitions_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"transitions": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let transitions = client.get_transitions("PROJ-1").await.unwrap();
assert!(transitions.is_empty());
}
#[tokio::test]
async fn get_transitions_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/transitions",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_transitions("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn do_transition_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.do_transition("PROJ-1", "21").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn do_transition_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(400).set_body_string("Invalid transition"),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.do_transition("PROJ-1", "999").await.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn search_confluence_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/content/search"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "12345",
"title": "Architecture Overview",
"_expandable": {"space": "/wiki/rest/api/space/ENG"}
},
{
"id": "67890",
"title": "Getting Started",
"_expandable": {"space": "/wiki/rest/api/space/DOC"}
}
],
"size": 2
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence("type = page", 25).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.results.len(), 2);
assert_eq!(result.results[0].id, "12345");
assert_eq!(result.results[0].title, "Architecture Overview");
assert_eq!(result.results[0].space_key, "ENG");
assert_eq!(result.results[1].space_key, "DOC");
}
#[tokio::test]
async fn search_confluence_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/content/search"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [], "size": 0})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.search_confluence("title = \"Nonexistent\"", 25)
.await
.unwrap();
assert_eq!(result.total, 0);
assert!(result.results.is_empty());
}
#[tokio::test]
async fn search_confluence_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/content/search"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Invalid CQL"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.search_confluence("bad cql !!!", 25)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn search_confluence_missing_space() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/content/search"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "111", "title": "No Space"}],
"size": 1
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence("type = page", 10).await.unwrap();
assert_eq!(result.results[0].space_key, "");
}
#[tokio::test]
async fn search_confluence_users_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "abc123",
"displayName": "Alice Smith",
"email": "alice@example.com"
},
"entityType": "user"
},
{
"user": {
"accountId": "def456",
"displayName": "Bob Jones",
"email": "bob@example.com"
},
"entityType": "user"
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence_users("alice", 25).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.users.len(), 2);
assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
assert_eq!(result.users[0].display_name, "Alice Smith");
assert_eq!(result.users[0].email.as_deref(), Some("alice@example.com"));
assert_eq!(result.users[1].account_id.as_deref(), Some("def456"));
assert_eq!(result.users[1].display_name, "Bob Jones");
}
#[tokio::test]
async fn search_confluence_users_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.search_confluence_users("nonexistent", 25)
.await
.unwrap();
assert_eq!(result.total, 0);
assert!(result.users.is_empty());
}
#[tokio::test]
async fn search_confluence_users_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.search_confluence_users("alice", 25)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn search_confluence_users_missing_email() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "xyz789",
"displayName": "No Email User"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.search_confluence_users("no email", 25)
.await
.unwrap();
assert_eq!(result.users.len(), 1);
assert_eq!(result.users[0].display_name, "No Email User");
assert!(result.users[0].email.is_none());
}
#[tokio::test]
async fn search_confluence_users_missing_account_id() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "abc123",
"displayName": "Alice Smith",
"email": "alice@example.com"
}
},
{
"user": {
"displayName": "App Bot",
"accountType": "app"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence_users("any", 25).await.unwrap();
assert_eq!(result.users.len(), 2);
assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
assert!(result.users[1].account_id.is_none());
assert_eq!(result.users[1].display_name, "App Bot");
}
#[tokio::test]
async fn search_confluence_users_uses_public_name_when_no_display_name() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "abc123",
"publicName": "alice.smith"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence_users("alice", 25).await.unwrap();
assert_eq!(result.users.len(), 1);
assert_eq!(result.users[0].display_name, "alice.smith");
}
#[tokio::test]
async fn search_confluence_users_skips_entries_without_user() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"title": "Not a user", "entityType": "content"},
{
"user": {
"accountId": "abc123",
"displayName": "Alice Smith"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence_users("alice", 25).await.unwrap();
assert_eq!(result.users.len(), 1);
assert_eq!(result.users[0].account_id.as_deref(), Some("abc123"));
}
#[tokio::test]
async fn search_confluence_users_pagination() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.and(wiremock::matchers::query_param("start", "0"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "page1",
"displayName": "User One"
}
}
],
"_links": {"next": "/wiki/rest/api/search/user?start=1"}
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/rest/api/search/user"))
.and(wiremock::matchers::query_param("start", "1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"user": {
"accountId": "page2",
"displayName": "User Two"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_confluence_users("user", 0).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.users[0].account_id.as_deref(), Some("page1"));
assert_eq!(result.users[1].account_id.as_deref(), Some("page2"));
}
#[tokio::test]
async fn get_boards_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"values": [
{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}},
{"id": 2, "name": "Kanban", "type": "kanban"}
],
"total": 2, "isLast": true
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_boards(None, None, 50).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.boards.len(), 2);
assert_eq!(result.boards[0].id, 1);
assert_eq!(result.boards[0].name, "PROJ Board");
assert_eq!(result.boards[0].board_type, "scrum");
assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
assert!(result.boards[1].project_key.is_none());
}
#[tokio::test]
async fn get_boards_with_filters() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.and(wiremock::matchers::query_param("projectKeyOrId", "PROJ"))
.and(wiremock::matchers::query_param("type", "scrum"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"values": [{"id": 1, "name": "PROJ Board", "type": "scrum", "location": {"projectKey": "PROJ"}}],
"total": 1, "isLast": true
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.get_boards(Some("PROJ"), Some("scrum"), 50)
.await
.unwrap();
assert_eq!(result.boards.len(), 1);
assert_eq!(result.boards[0].project_key.as_deref(), Some("PROJ"));
}
#[tokio::test]
async fn search_issues_paginates_with_token() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.and(wiremock::matchers::body_partial_json(serde_json::json!({"jql": "project = PROJ"})))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"issues": [{"key": "PROJ-1", "fields": {"summary": "First", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}],
"nextPageToken": "token123"
}),
))
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.and(wiremock::matchers::body_partial_json(serde_json::json!({"nextPageToken": "token123"})))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"issues": [{"key": "PROJ-2", "fields": {"summary": "Second", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}]
}),
))
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_issues("project = PROJ", 0).await.unwrap();
assert_eq!(result.issues.len(), 2);
assert_eq!(result.issues[0].key, "PROJ-1");
assert_eq!(result.issues[1].key, "PROJ-2");
}
#[tokio::test]
async fn search_issues_respects_limit() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/search/jql"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"issues": [
{"key": "PROJ-1", "fields": {"summary": "A", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}},
{"key": "PROJ-2", "fields": {"summary": "B", "description": null, "status": null, "issuetype": null, "assignee": null, "priority": null, "labels": []}}
],
"nextPageToken": "more"
}),
))
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.search_issues("project = PROJ", 2).await.unwrap();
assert_eq!(result.issues.len(), 2);
}
#[tokio::test]
async fn get_boards_paginates_with_offset() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.and(wiremock::matchers::query_param("startAt", "0"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": 1, "name": "Board 1", "type": "scrum"}],
"total": 2, "isLast": false
})),
)
.up_to_n_times(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.and(wiremock::matchers::query_param("startAt", "1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": 2, "name": "Board 2", "type": "kanban"}],
"total": 2, "isLast": true
})),
)
.up_to_n_times(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_boards(None, None, 0).await.unwrap();
assert_eq!(result.boards.len(), 2);
assert_eq!(result.boards[0].name, "Board 1");
assert_eq!(result.boards[1].name, "Board 2");
}
#[tokio::test]
async fn get_boards_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [], "total": 0})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_boards(None, None, 50).await.unwrap();
assert!(result.boards.is_empty());
}
#[tokio::test]
async fn get_boards_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_boards(None, None, 50).await.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[tokio::test]
async fn get_board_issues_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/1/issue"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [{
"key": "PROJ-1",
"fields": {
"summary": "Board issue",
"description": null,
"status": {"name": "Open"},
"issuetype": {"name": "Task"},
"assignee": null,
"priority": null,
"labels": []
}
}],
"total": 1, "isLast": true
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_board_issues(1, None, 50).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.issues[0].key, "PROJ-1");
assert_eq!(result.issues[0].summary, "Board issue");
}
#[tokio::test]
async fn get_board_issues_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/999/issue"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_board_issues(999, None, 50).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_sprints_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"values": [
{"id": 10, "name": "Sprint 1", "state": "closed", "startDate": "2026-03-01", "endDate": "2026-03-14", "goal": "MVP"},
{"id": 11, "name": "Sprint 2", "state": "active", "startDate": "2026-03-15", "endDate": "2026-03-28"}
],
"total": 2, "isLast": true
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_sprints(1, None, 50).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.sprints.len(), 2);
assert_eq!(result.sprints[0].id, 10);
assert_eq!(result.sprints[0].name, "Sprint 1");
assert_eq!(result.sprints[0].state, "closed");
assert_eq!(result.sprints[0].goal.as_deref(), Some("MVP"));
assert!(result.sprints[1].goal.is_none());
}
#[tokio::test]
async fn get_sprints_with_state_filter() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
.and(wiremock::matchers::query_param("state", "active"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": 11, "name": "Sprint 2", "state": "active"}],
"total": 1, "isLast": true
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_sprints(1, Some("active"), 50).await.unwrap();
assert_eq!(result.sprints.len(), 1);
assert_eq!(result.sprints[0].state, "active");
}
#[tokio::test]
async fn get_sprints_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_sprints(999, None, 50).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_sprint_issues_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [{
"key": "PROJ-1",
"fields": {
"summary": "Sprint issue",
"description": null,
"status": {"name": "In Progress"},
"issuetype": {"name": "Story"},
"assignee": {"displayName": "Alice"},
"priority": null,
"labels": []
}
}],
"total": 1, "isLast": true
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_sprint_issues(10, None, 50).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.issues[0].key, "PROJ-1");
assert_eq!(result.issues[0].assignee.as_deref(), Some("Alice"));
}
#[tokio::test]
async fn get_sprint_issues_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_sprint_issues(999, None, 50).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn add_issues_to_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.add_issues_to_sprint(10, &["PROJ-1", "PROJ-2"]).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_issues_to_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.add_issues_to_sprint(999, &["NOPE-1"])
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn create_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 42,
"name": "Sprint 5",
"state": "future",
"startDate": "2026-05-01",
"endDate": "2026-05-14",
"goal": "Ship v2"
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let sprint = client
.create_sprint(
1,
"Sprint 5",
Some("2026-05-01"),
Some("2026-05-14"),
Some("Ship v2"),
)
.await
.unwrap();
assert_eq!(sprint.id, 42);
assert_eq!(sprint.name, "Sprint 5");
assert_eq!(sprint.state, "future");
assert_eq!(sprint.goal.as_deref(), Some("Ship v2"));
}
#[tokio::test]
async fn create_sprint_minimal() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
.respond_with(wiremock::ResponseTemplate::new(201).set_body_json(
serde_json::json!({"id": 43, "name": "Sprint 6", "state": "future"}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let sprint = client
.create_sprint(1, "Sprint 6", None, None, None)
.await
.unwrap();
assert_eq!(sprint.id, 43);
assert!(sprint.start_date.is_none());
}
#[tokio::test]
async fn create_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.create_sprint(999, "Bad", None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn update_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": 42, "name": "Sprint 5 Updated", "state": "active"}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.update_sprint(
42,
Some("Sprint 5 Updated"),
Some("active"),
None,
None,
None,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_sprint_all_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": 42, "name": "Sprint 5", "state": "active"}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.update_sprint(
42,
Some("Sprint 5"),
Some("active"),
Some("2026-05-01"),
Some("2026-05-14"),
Some("Ship v2"),
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.update_sprint(999, Some("Nope"), None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_issue_links_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"fields": {
"issuelinks": [
{
"id": "100",
"type": {"name": "Blocks"},
"outwardIssue": {"key": "PROJ-2", "fields": {"summary": "Blocked issue"}}
},
{
"id": "101",
"type": {"name": "Relates"},
"inwardIssue": {"key": "PROJ-3", "fields": {"summary": "Related issue"}}
}
]
}
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let links = client.get_issue_links("PROJ-1").await.unwrap();
assert_eq!(links.len(), 2);
assert_eq!(links[0].id, "100");
assert_eq!(links[0].link_type, "Blocks");
assert_eq!(links[0].direction, "outward");
assert_eq!(links[0].linked_issue_key, "PROJ-2");
assert_eq!(links[0].linked_issue_summary, "Blocked issue");
assert_eq!(links[1].id, "101");
assert_eq!(links[1].direction, "inward");
assert_eq!(links[1].linked_issue_key, "PROJ-3");
}
#[tokio::test]
async fn get_issue_links_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"fields": {"issuelinks": []}})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let links = client.get_issue_links("PROJ-1").await.unwrap();
assert!(links.is_empty());
}
#[tokio::test]
async fn get_issue_links_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_issue_links("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_link_types_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"issueLinkTypes": [{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, {"id": "2", "name": "Clones", "inward": "is cloned by", "outward": "clones"}]})))
.expect(1).mount(&server).await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let types = client.get_link_types().await.unwrap();
assert_eq!(types.len(), 2);
assert_eq!(types[0].name, "Blocks");
assert_eq!(types[0].inward, "is blocked by");
}
#[tokio::test]
async fn get_link_types_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issueLinkType"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_link_types().await.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[tokio::test]
async fn create_issue_link_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issueLink"))
.respond_with(wiremock::ResponseTemplate::new(201))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
assert!(client
.create_issue_link("Blocks", "PROJ-1", "PROJ-2")
.await
.is_ok());
}
#[tokio::test]
async fn create_issue_link_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issueLink"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.create_issue_link("Invalid", "NOPE-1", "NOPE-2")
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn remove_issue_link_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
assert!(client.remove_issue_link("12345").await.is_ok());
}
#[tokio::test]
async fn remove_issue_link_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issueLink/99999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.remove_issue_link("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn link_to_epic_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
assert!(client.link_to_epic("EPIC-1", "PROJ-2").await.is_ok());
}
#[tokio::test]
async fn link_to_epic_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-2"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Not an epic"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.link_to_epic("NOPE-1", "PROJ-2").await.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn get_bytes_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/file.bin"))
.and(wiremock::matchers::header("Accept", "*/*"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary content"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let data = client
.get_bytes(&format!("{}/file.bin", server.uri()))
.await
.unwrap();
assert_eq!(&data[..], b"binary content");
}
#[tokio::test]
async fn get_bytes_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/missing.bin"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.get_bytes(&format!("{}/missing.bin", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_attachments_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"fields": {
"attachment": [
{"id": "1", "filename": "screenshot.png", "mimeType": "image/png", "size": 12345, "content": "https://org.atlassian.net/attachment/1"},
{"id": "2", "filename": "report.pdf", "mimeType": "application/pdf", "size": 99999, "content": "https://org.atlassian.net/attachment/2"}
]
}
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let attachments = client.get_attachments("PROJ-1").await.unwrap();
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].filename, "screenshot.png");
assert_eq!(attachments[0].mime_type, "image/png");
assert_eq!(attachments[0].size, 12345);
assert_eq!(attachments[1].filename, "report.pdf");
}
#[tokio::test]
async fn get_attachments_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"fields": {"attachment": []}})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let attachments = client.get_attachments("PROJ-1").await.unwrap();
assert!(attachments.is_empty());
}
#[tokio::test]
async fn get_attachments_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_attachments("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_changelog_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/changelog",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"values": [
{
"id": "100",
"author": {"displayName": "Alice"},
"created": "2026-04-01T10:00:00.000+0000",
"items": [
{"field": "status", "fromString": "Open", "toString": "In Progress"},
{"field": "assignee", "fromString": null, "toString": "Bob"}
]
},
{
"id": "101",
"author": null,
"created": "2026-04-02T14:00:00.000+0000",
"items": [{"field": "priority", "fromString": "Medium", "toString": "High"}]
}
],
"isLast": true
}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "100");
assert_eq!(entries[0].author, "Alice");
assert_eq!(entries[0].items.len(), 2);
assert_eq!(entries[0].items[0].field, "status");
assert_eq!(entries[0].items[0].from_string.as_deref(), Some("Open"));
assert_eq!(
entries[0].items[0].to_string.as_deref(),
Some("In Progress")
);
assert_eq!(entries[0].items[1].from_string, None);
assert_eq!(entries[1].author, "");
}
#[tokio::test]
async fn get_changelog_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/changelog",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let entries = client.get_changelog("PROJ-1", 50).await.unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn get_changelog_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/changelog",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_changelog("NOPE-1", 50).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_fields_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/field"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!([
{"id": "summary", "name": "Summary", "custom": false, "schema": {"type": "string"}},
{"id": "customfield_10001", "name": "Story Points", "custom": true, "schema": {"type": "number"}},
{"id": "labels", "name": "Labels", "custom": false}
]),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let fields = client.get_fields().await.unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].id, "summary");
assert_eq!(fields[0].name, "Summary");
assert!(!fields[0].custom);
assert_eq!(fields[0].schema_type.as_deref(), Some("string"));
assert_eq!(fields[1].id, "customfield_10001");
assert!(fields[1].custom);
assert!(fields[2].schema_type.is_none());
}
#[tokio::test]
async fn get_fields_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/field"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_fields().await.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[tokio::test]
async fn get_field_contexts_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"values": [{"id": "12345"}, {"id": "67890"}]}),
),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let contexts = client
.get_field_contexts("customfield_10001")
.await
.unwrap();
assert_eq!(contexts.len(), 2);
assert_eq!(contexts[0], "12345");
}
#[tokio::test]
async fn get_field_contexts_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/nonexistent/context",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_field_contexts("nonexistent").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_field_contexts_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_99999/context",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let contexts = client
.get_field_contexts("customfield_99999")
.await
.unwrap();
assert!(contexts.is_empty());
}
#[tokio::test]
async fn get_field_options_auto_discovers_context() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [{"id": "12345"}]})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context/12345/option",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [{"id": "1", "value": "High"}]})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let options = client
.get_field_options("customfield_10001", None)
.await
.unwrap();
assert_eq!(options.len(), 1);
assert_eq!(options[0].value, "High");
}
#[tokio::test]
async fn get_field_options_no_context_errors() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_99999/context",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.get_field_options("customfield_99999", None)
.await
.unwrap_err();
assert!(err.to_string().contains("No contexts found"));
}
#[tokio::test]
async fn get_field_options_with_explicit_context() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context/12345/option",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"values": [
{"id": "1", "value": "High"},
{"id": "2", "value": "Medium"},
{"id": "3", "value": "Low"}
]}),
))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let options = client
.get_field_options("customfield_10001", Some("12345"))
.await
.unwrap();
assert_eq!(options.len(), 3);
assert_eq!(options[0].id, "1");
assert_eq!(options[0].value, "High");
}
#[tokio::test]
async fn get_field_options_with_context() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context/12345/option",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"values": [{"id": "1", "value": "Option A"}]}),
),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let options = client
.get_field_options("customfield_10001", Some("12345"))
.await
.unwrap();
assert_eq!(options.len(), 1);
assert_eq!(options[0].value, "Option A");
}
#[tokio::test]
async fn get_field_options_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/nonexistent/context/99999/option",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.get_field_options("nonexistent", Some("99999"))
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_projects_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [
{
"id": "10001",
"key": "PROJ",
"name": "My Project",
"projectTypeKey": "software",
"lead": {"displayName": "Alice"}
},
{
"id": "10002",
"key": "OPS",
"name": "Operations",
"projectTypeKey": "business",
"lead": null
}
],
"total": 2, "isLast": true
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_projects(50).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.projects.len(), 2);
assert_eq!(result.projects[0].key, "PROJ");
assert_eq!(result.projects[0].name, "My Project");
assert_eq!(result.projects[0].project_type.as_deref(), Some("software"));
assert_eq!(result.projects[0].lead.as_deref(), Some("Alice"));
assert_eq!(result.projects[1].key, "OPS");
assert!(result.projects[1].lead.is_none());
}
#[tokio::test]
async fn get_projects_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [], "total": 0})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_projects(50).await.unwrap();
assert_eq!(result.total, 0);
assert!(result.projects.is_empty());
}
#[tokio::test]
async fn get_projects_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_projects(50).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn delete_issue_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-42"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.delete_issue("PROJ-42").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn delete_issue_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.delete_issue("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn delete_issue_forbidden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.delete_issue("PROJ-1").await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn get_watchers_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"watchCount": 2,
"watchers": [
{
"accountId": "abc123",
"displayName": "Alice",
"emailAddress": "alice@example.com"
},
{
"accountId": "def456",
"displayName": "Bob"
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_watchers("PROJ-1").await.unwrap();
assert_eq!(result.watch_count, 2);
assert_eq!(result.watchers.len(), 2);
assert_eq!(result.watchers[0].display_name, "Alice");
assert_eq!(result.watchers[0].account_id, "abc123");
assert_eq!(
result.watchers[0].email_address.as_deref(),
Some("alice@example.com")
);
assert_eq!(result.watchers[1].display_name, "Bob");
assert!(result.watchers[1].email_address.is_none());
}
#[tokio::test]
async fn get_watchers_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"watchCount": 0,
"watchers": []
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_watchers("PROJ-1").await.unwrap();
assert_eq!(result.watch_count, 0);
assert!(result.watchers.is_empty());
}
#[tokio::test]
async fn get_watchers_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/watchers",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_watchers("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn add_watcher_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.and(wiremock::matchers::body_json(serde_json::json!("abc123")))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.add_watcher("PROJ-1", "abc123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_watcher_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.add_watcher("PROJ-1", "abc123").await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn remove_watcher_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.and(wiremock::matchers::query_param("accountId", "abc123"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.remove_watcher("PROJ-1", "abc123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn remove_watcher_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/watchers",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.remove_watcher("PROJ-1", "abc123").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_myself_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"displayName": "Alice Smith",
"emailAddress": "alice@example.com",
"accountId": "abc123"
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let user = client.get_myself().await.unwrap();
assert_eq!(user.display_name, "Alice Smith");
assert_eq!(user.email_address.as_deref(), Some("alice@example.com"));
assert_eq!(user.account_id, "abc123");
}
#[tokio::test]
async fn get_myself_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_myself().await.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[tokio::test]
async fn get_issue_id_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "12345", "key": "PROJ-1", "fields": {}}),
),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let id = client.get_issue_id("PROJ-1").await.unwrap();
assert_eq!(id, "12345");
}
#[tokio::test]
async fn get_issue_id_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_issue_id("NOPE-1").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_dev_status_summary_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {
"overall": {"count": 2},
"byInstanceType": {"GitHub": {"count": 2, "name": "GitHub"}}
},
"branch": {
"overall": {"count": 1},
"byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
},
"repository": {
"overall": {"count": 1},
"byInstanceType": {}
}
}
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
assert_eq!(summary.pullrequest.count, 2);
assert_eq!(summary.pullrequest.providers, vec!["GitHub"]);
assert_eq!(summary.branch.count, 1);
assert_eq!(summary.repository.count, 1);
assert!(summary.repository.providers.is_empty());
}
#[tokio::test]
async fn get_dev_status_summary_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client.get_dev_status_summary("PROJ-1").await.unwrap_err();
assert!(err.to_string().contains("403"));
}
async fn mount_issue_id_mock(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "10001", "key": "PROJ-1", "fields": {}}),
),
)
.mount(server)
.await;
}
async fn mount_summary_mock(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {
"overall": {"count": 1},
"byInstanceType": {"GitHub": {"count": 1, "name": "GitHub"}}
},
"branch": {
"overall": {"count": 0},
"byInstanceType": {}
},
"repository": {
"overall": {"count": 0},
"byInstanceType": {}
}
}
})),
)
.mount(server)
.await;
}
fn dev_status_detail_response() -> serde_json::Value {
serde_json::json!({
"detail": [{
"pullRequests": [{
"id": "#42",
"name": "Fix login bug",
"status": "MERGED",
"url": "https://github.com/org/repo/pull/42",
"repositoryName": "org/repo",
"source": {"branch": "fix-login"},
"destination": {"branch": "main"},
"author": {"name": "Alice"},
"reviewers": [{"name": "Bob"}],
"commentCount": 3,
"lastUpdate": "2024-01-15T10:30:00.000+0000"
}],
"branches": [{
"name": "fix-login",
"url": "https://github.com/org/repo/tree/fix-login",
"repositoryName": "org/repo",
"createPullRequestUrl": "https://github.com/org/repo/compare/fix-login",
"lastCommit": {
"id": "abc123def456",
"displayId": "abc123d",
"message": "Fix the login",
"author": {"name": "Alice"},
"authorTimestamp": "2024-01-14T08:00:00.000+0000",
"url": "https://github.com/org/repo/commit/abc123d",
"fileCount": 2,
"merge": false
}
}],
"repositories": [{
"name": "org/repo",
"url": "https://github.com/org/repo",
"commits": [{
"id": "abc123def456",
"displayId": "abc123d",
"message": "Fix the login",
"author": {"name": "Alice"},
"authorTimestamp": "2024-01-14T08:00:00.000+0000",
"url": "https://github.com/org/repo/commit/abc123d",
"fileCount": 2,
"merge": false
}]
}],
"_instance": {"name": "GitHub", "type": "GitHub"}
}]
})
}
#[tokio::test]
async fn get_dev_status_pullrequest_fields() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.and(wiremock::matchers::query_param("dataType", "pullrequest"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
.await
.unwrap();
assert_eq!(status.pull_requests.len(), 1);
let pr = &status.pull_requests[0];
assert_eq!(pr.id, "#42");
assert_eq!(pr.status, "MERGED");
assert_eq!(pr.author.as_deref(), Some("Alice"));
assert_eq!(pr.reviewers, vec!["Bob"]);
assert_eq!(pr.comment_count, Some(3));
assert!(pr.last_update.is_some());
assert_eq!(pr.source_branch, "fix-login");
assert_eq!(pr.destination_branch, "main");
}
#[tokio::test]
async fn get_dev_status_branch_fields() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.and(wiremock::matchers::query_param("dataType", "branch"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
.await
.unwrap();
assert_eq!(status.branches.len(), 1);
let branch = &status.branches[0];
assert_eq!(branch.name, "fix-login");
assert!(branch.create_pr_url.is_some());
let commit = branch.last_commit.as_ref().unwrap();
assert_eq!(commit.display_id, "abc123d");
assert_eq!(commit.file_count, 2);
assert!(!commit.merge);
}
#[tokio::test]
async fn get_dev_status_repository_with_commits() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.and(wiremock::matchers::query_param("dataType", "repository"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", Some("repository"), Some("GitHub"))
.await
.unwrap();
assert_eq!(status.repositories.len(), 1);
assert_eq!(status.repositories[0].commits.len(), 1);
assert_eq!(status.repositories[0].commits[0].display_id, "abc123d");
assert_eq!(
status.repositories[0].commits[0].author.as_deref(),
Some("Alice")
);
}
#[tokio::test]
async fn get_dev_status_auto_discovers_providers() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
mount_summary_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(dev_status_detail_response()),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", Some("pullrequest"), None)
.await
.unwrap();
assert_eq!(status.pull_requests.len(), 1);
assert_eq!(status.pull_requests[0].name, "Fix login bug");
}
#[tokio::test]
async fn get_dev_status_empty_response() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"detail": []})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", None, Some("GitHub"))
.await
.unwrap();
assert!(status.pull_requests.is_empty());
assert!(status.branches.is_empty());
assert!(status.repositories.is_empty());
}
#[tokio::test]
async fn get_dev_status_detail_api_error() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("Server Error"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let err = client
.get_dev_status("PROJ-1", Some("pullrequest"), Some("GitHub"))
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn get_dev_status_with_data_type_filter() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/detail",
))
.and(wiremock::matchers::query_param("dataType", "branch"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{
"pullRequests": [],
"branches": [{
"name": "feature-x",
"url": "https://github.com/org/repo/tree/feature-x",
"repositoryName": "org/repo"
}],
"repositories": []
}]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let status = client
.get_dev_status("PROJ-1", Some("branch"), Some("GitHub"))
.await
.unwrap();
assert!(status.pull_requests.is_empty());
assert_eq!(status.branches.len(), 1);
assert_eq!(status.branches[0].name, "feature-x");
assert!(status.branches[0].last_commit.is_none());
assert!(status.branches[0].create_pr_url.is_none());
assert!(status.repositories.is_empty());
}
#[tokio::test]
async fn get_dev_status_summary_empty() {
let server = wiremock::MockServer::start().await;
mount_issue_id_mock(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/dev-status/1.0/issue/summary",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"summary": {}})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let summary = client.get_dev_status_summary("PROJ-1").await.unwrap();
assert_eq!(summary.pullrequest.count, 0);
assert_eq!(summary.branch.count, 0);
assert_eq!(summary.repository.count, 0);
}
#[tokio::test]
async fn convert_commit_maps_all_fields() {
let internal = DevStatusCommit {
id: "abc123".to_string(),
display_id: "abc".to_string(),
message: "Test commit".to_string(),
author: Some(DevStatusAuthor {
name: "Alice".to_string(),
}),
author_timestamp: Some("2024-01-01T00:00:00.000+0000".to_string()),
url: "https://example.com/commit/abc".to_string(),
file_count: 5,
merge: true,
};
let public = AtlassianClient::convert_commit(internal);
assert_eq!(public.id, "abc123");
assert_eq!(public.display_id, "abc");
assert_eq!(public.message, "Test commit");
assert_eq!(public.author.as_deref(), Some("Alice"));
assert!(public.timestamp.is_some());
assert_eq!(public.file_count, 5);
assert!(public.merge);
}
#[tokio::test]
async fn convert_commit_no_author() {
let internal = DevStatusCommit {
id: "def456".to_string(),
display_id: "def".to_string(),
message: "Anonymous".to_string(),
author: None,
author_timestamp: None,
url: "https://example.com/commit/def".to_string(),
file_count: 0,
merge: false,
};
let public = AtlassianClient::convert_commit(internal);
assert!(public.author.is_none());
assert!(public.timestamp.is_none());
}
#[test]
fn extract_worklog_comment_none() {
assert_eq!(AtlassianClient::extract_worklog_comment(None), None);
}
#[test]
fn extract_worklog_comment_valid_adf() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "Fixed the login bug"}]
}]
});
let result = AtlassianClient::extract_worklog_comment(Some(&adf));
assert_eq!(result.as_deref(), Some("Fixed the login bug"));
}
#[test]
fn extract_worklog_comment_empty_adf() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": []
});
let result = AtlassianClient::extract_worklog_comment(Some(&adf));
assert_eq!(result, None);
}
#[test]
fn extract_worklog_comment_invalid_json() {
let invalid = serde_json::json!({"not": "adf"});
let result = AtlassianClient::extract_worklog_comment(Some(&invalid));
assert_eq!(result, None);
}
#[test]
fn worklog_response_deserializes() {
let json = r#"{
"worklogs": [
{
"id": "100",
"author": {"displayName": "Alice"},
"timeSpent": "2h",
"timeSpentSeconds": 7200,
"started": "2026-04-16T09:00:00.000+0000",
"comment": {
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging"}]}]
}
},
{
"id": "101",
"author": {"displayName": "Bob"},
"timeSpent": "1d",
"timeSpentSeconds": 28800,
"started": "2026-04-15T10:00:00.000+0000"
}
],
"total": 2
}"#;
let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 2);
assert_eq!(resp.worklogs.len(), 2);
assert_eq!(resp.worklogs[0].id, "100");
assert_eq!(resp.worklogs[0].time_spent.as_deref(), Some("2h"));
assert_eq!(resp.worklogs[0].time_spent_seconds, 7200);
assert!(resp.worklogs[0].comment.is_some());
assert!(resp.worklogs[1].comment.is_none());
}
#[test]
fn worklog_response_empty() {
let json = r#"{"worklogs": [], "total": 0}"#;
let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.total, 0);
assert!(resp.worklogs.is_empty());
}
#[test]
fn worklog_response_missing_optional_fields() {
let json = r#"{
"worklogs": [{
"id": "200",
"timeSpentSeconds": 3600
}],
"total": 1
}"#;
let resp: JiraWorklogResponse = serde_json::from_str(json).unwrap();
assert!(resp.worklogs[0].author.is_none());
assert!(resp.worklogs[0].time_spent.is_none());
assert!(resp.worklogs[0].started.is_none());
}
#[tokio::test]
async fn get_worklogs_success() {
let server = wiremock::MockServer::start().await;
let worklog_json = serde_json::json!({
"worklogs": [
{
"id": "100",
"author": {"displayName": "Alice"},
"timeSpent": "2h",
"timeSpentSeconds": 7200,
"started": "2026-04-16T09:00:00.000+0000",
"comment": {
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Debugging login"}]}]
}
},
{
"id": "101",
"author": {"displayName": "Bob"},
"timeSpent": "1d",
"timeSpentSeconds": 28800,
"started": "2026-04-15T10:00:00.000+0000"
}
],
"total": 2
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.worklogs.len(), 2);
assert_eq!(result.worklogs[0].author, "Alice");
assert_eq!(result.worklogs[0].time_spent, "2h");
assert_eq!(result.worklogs[0].time_spent_seconds, 7200);
assert_eq!(
result.worklogs[0].comment.as_deref(),
Some("Debugging login")
);
assert_eq!(result.worklogs[1].author, "Bob");
assert_eq!(result.worklogs[1].comment, None);
}
#[tokio::test]
async fn get_worklogs_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"worklogs": [], "total": 0})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_worklogs("PROJ-1", 50).await.unwrap();
assert_eq!(result.total, 0);
assert!(result.worklogs.is_empty());
}
#[tokio::test]
async fn get_worklogs_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_worklogs("PROJ-1", 50).await;
assert!(result.is_err());
}
#[tokio::test]
async fn add_worklog_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(201))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.add_worklog("PROJ-1", "2h", None, None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_worklog_with_all_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(201))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client
.add_worklog(
"PROJ-1",
"2h 30m",
Some("2026-04-16T09:00:00.000+0000"),
Some("Fixed the bug"),
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_worklog_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.add_worklog("PROJ-1", "2h", None, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_worklogs_respects_limit() {
let server = wiremock::MockServer::start().await;
let worklog_json = serde_json::json!({
"worklogs": [
{"id": "1", "author": {"displayName": "A"}, "timeSpent": "1h", "timeSpentSeconds": 3600, "started": "2026-04-16T09:00:00.000+0000"},
{"id": "2", "author": {"displayName": "B"}, "timeSpent": "2h", "timeSpentSeconds": 7200, "started": "2026-04-16T10:00:00.000+0000"},
{"id": "3", "author": {"displayName": "C"}, "timeSpent": "3h", "timeSpentSeconds": 10800, "started": "2026-04-16T11:00:00.000+0000"}
],
"total": 3
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(worklog_json))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let result = client.get_worklogs("PROJ-1", 2).await.unwrap();
assert_eq!(result.worklogs.len(), 2);
assert_eq!(result.total, 3);
}
}
impl AtlassianClient {
pub fn new(instance_url: &str, email: &str, api_token: &str) -> Result<Self> {
let client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.context("Failed to build HTTP client")?;
let credentials = format!("{email}:{api_token}");
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
let auth_header = format!("Basic {encoded}");
Ok(Self {
client,
instance_url: instance_url.trim_end_matches('/').to_string(),
auth_header,
})
}
pub fn from_credentials(creds: &crate::atlassian::auth::AtlassianCredentials) -> Result<Self> {
Self::new(&creds.instance_url, &creds.email, &creds.api_token)
}
#[must_use]
pub fn instance_url(&self) -> &str {
&self.instance_url
}
pub async fn get_json(&self, url: &str) -> Result<reqwest::Response> {
for attempt in 0..=MAX_RETRIES {
let response = self
.client
.get(url)
.header("Authorization", &self.auth_header)
.header("Accept", "application/json")
.send()
.await
.context("Failed to send GET request to Atlassian API")?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
Self::wait_for_retry(&response, attempt).await;
}
unreachable!()
}
pub async fn put_json<T: serde::Serialize + Sync + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<reqwest::Response> {
for attempt in 0..=MAX_RETRIES {
let response = self
.client
.put(url)
.header("Authorization", &self.auth_header)
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.context("Failed to send PUT request to Atlassian API")?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
Self::wait_for_retry(&response, attempt).await;
}
unreachable!()
}
pub async fn post_json<T: serde::Serialize + Sync + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<reqwest::Response> {
for attempt in 0..=MAX_RETRIES {
let response = self
.client
.post(url)
.header("Authorization", &self.auth_header)
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.context("Failed to send POST request to Atlassian API")?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
Self::wait_for_retry(&response, attempt).await;
}
unreachable!()
}
pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
let response = self.get_json_raw_accept(url, "*/*").await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let bytes = response
.bytes()
.await
.context("Failed to read response bytes")?;
Ok(bytes.to_vec())
}
pub async fn delete(&self, url: &str) -> Result<reqwest::Response> {
for attempt in 0..=MAX_RETRIES {
let response = self
.client
.delete(url)
.header("Authorization", &self.auth_header)
.send()
.await
.context("Failed to send DELETE request to Atlassian API")?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
Self::wait_for_retry(&response, attempt).await;
}
unreachable!()
}
async fn get_json_raw_accept(&self, url: &str, accept: &str) -> Result<reqwest::Response> {
for attempt in 0..=MAX_RETRIES {
let response = self
.client
.get(url)
.header("Authorization", &self.auth_header)
.header("Accept", accept)
.send()
.await
.context("Failed to send GET request to Atlassian API")?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
Self::wait_for_retry(&response, attempt).await;
}
unreachable!()
}
async fn wait_for_retry(response: &reqwest::Response, attempt: u32) {
let delay = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or_else(|| DEFAULT_RETRY_DELAY_SECS.pow(attempt + 1));
eprintln!(
"Rate limited (429). Retrying in {delay}s (attempt {})...",
attempt + 1
);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
pub async fn get_issue(&self, key: &str) -> Result<JiraIssue> {
self.get_issue_with_fields(key, FieldSelection::Standard)
.await
}
pub async fn get_issue_with_fields(
&self,
key: &str,
selection: FieldSelection,
) -> Result<JiraIssue> {
const STANDARD_FIELDS: &str =
"summary,description,status,issuetype,assignee,priority,labels";
let fields_param = match &selection {
FieldSelection::Standard => STANDARD_FIELDS.to_string(),
FieldSelection::Named(names) => {
let mut parts: Vec<&str> = STANDARD_FIELDS.split(',').collect();
parts.extend(names.iter().map(String::as_str));
parts.join(",")
}
FieldSelection::All => "*all".to_string(),
};
let base = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
let url = reqwest::Url::parse_with_params(
&base,
&[
("fields", fields_param.as_str()),
("expand", "names,schema"),
],
)
.context("Failed to build JIRA issue URL")?;
let response = self
.client
.get(url)
.header("Authorization", &self.auth_header)
.header("Accept", "application/json")
.send()
.await
.context("Failed to send request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let envelope: JiraIssueEnvelope = response
.json()
.await
.context("Failed to parse JIRA issue response")?;
Ok(envelope.into_issue(&selection))
}
pub async fn update_issue(
&self,
key: &str,
description_adf: &AdfDocument,
summary: Option<&str>,
) -> Result<()> {
self.update_issue_with_custom_fields(
key,
description_adf,
summary,
&std::collections::BTreeMap::new(),
)
.await
}
pub async fn update_issue_with_custom_fields(
&self,
key: &str,
description_adf: &AdfDocument,
summary: Option<&str>,
custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
let mut fields = serde_json::Map::new();
fields.insert(
"description".to_string(),
serde_json::to_value(description_adf).context("Failed to serialize ADF document")?,
);
if let Some(summary_text) = summary {
fields.insert(
"summary".to_string(),
serde_json::Value::String(summary_text.to_string()),
);
}
for (id, value) in custom_fields {
fields.insert(id.clone(), value.clone());
}
let body = serde_json::json!({ "fields": fields });
let response = self
.client
.put(&url)
.header("Authorization", &self.auth_header)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("Failed to send update request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_editmeta(&self, key: &str) -> Result<EditMeta> {
let url = format!("{}/rest/api/3/issue/{}/editmeta", self.instance_url, key);
let response = self
.client
.get(&url)
.header("Authorization", &self.auth_header)
.header("Accept", "application/json")
.send()
.await
.context("Failed to send editmeta request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let raw: JiraEditMetaResponse = response
.json()
.await
.context("Failed to parse JIRA editmeta response")?;
let fields = raw
.fields
.into_iter()
.map(|(id, field)| {
let schema = field.schema.map_or_else(
|| EditMetaSchema {
kind: String::new(),
custom: None,
},
|s| EditMetaSchema {
kind: s.kind.unwrap_or_default(),
custom: s.custom,
},
);
(
id,
EditMetaField {
name: field.name.unwrap_or_default(),
schema,
},
)
})
.collect();
Ok(EditMeta { fields })
}
pub async fn create_issue(
&self,
project_key: &str,
issue_type: &str,
summary: &str,
description_adf: Option<&AdfDocument>,
labels: &[String],
) -> Result<JiraCreatedIssue> {
self.create_issue_with_custom_fields(
project_key,
issue_type,
summary,
description_adf,
labels,
&std::collections::BTreeMap::new(),
)
.await
}
pub async fn create_issue_with_custom_fields(
&self,
project_key: &str,
issue_type: &str,
summary: &str,
description_adf: Option<&AdfDocument>,
labels: &[String],
custom_fields: &std::collections::BTreeMap<String, serde_json::Value>,
) -> Result<JiraCreatedIssue> {
let url = format!("{}/rest/api/3/issue", self.instance_url);
let mut fields = serde_json::Map::new();
fields.insert(
"project".to_string(),
serde_json::json!({ "key": project_key }),
);
fields.insert(
"issuetype".to_string(),
serde_json::json!({ "name": issue_type }),
);
fields.insert(
"summary".to_string(),
serde_json::Value::String(summary.to_string()),
);
if let Some(adf) = description_adf {
fields.insert(
"description".to_string(),
serde_json::to_value(adf).context("Failed to serialize ADF document")?,
);
}
if !labels.is_empty() {
fields.insert("labels".to_string(), serde_json::to_value(labels)?);
}
for (id, value) in custom_fields {
fields.insert(id.clone(), value.clone());
}
let body = serde_json::json!({ "fields": fields });
let response = self
.post_json(&url, &body)
.await
.context("Failed to send create request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let create_response: JiraCreateResponse = response
.json()
.await
.context("Failed to parse JIRA create response")?;
Ok(JiraCreatedIssue {
key: create_response.key,
id: create_response.id,
self_url: create_response.self_url,
})
}
pub async fn get_createmeta(&self, project_key: &str, issue_type: &str) -> Result<EditMeta> {
let base = format!("{}/rest/api/3/issue/createmeta", self.instance_url);
let url = reqwest::Url::parse_with_params(
&base,
&[
("projectKeys", project_key),
("issuetypeNames", issue_type),
("expand", "projects.issuetypes.fields"),
],
)
.context("Failed to build JIRA createmeta URL")?;
let response = self
.client
.get(url)
.header("Authorization", &self.auth_header)
.header("Accept", "application/json")
.send()
.await
.context("Failed to send createmeta request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let raw: JiraCreateMetaResponse = response
.json()
.await
.context("Failed to parse JIRA createmeta response")?;
let Some(project) = raw.projects.into_iter().next() else {
return Ok(EditMeta::default());
};
let Some(issuetype) = project.issuetypes.into_iter().next() else {
return Ok(EditMeta::default());
};
let fields = issuetype
.fields
.into_iter()
.map(|(id, field)| {
let schema = field.schema.map_or_else(
|| EditMetaSchema {
kind: String::new(),
custom: None,
},
|s| EditMetaSchema {
kind: s.kind.unwrap_or_default(),
custom: s.custom,
},
);
(
id,
EditMetaField {
name: field.name.unwrap_or_default(),
schema,
},
)
})
.collect();
Ok(EditMeta { fields })
}
pub async fn get_comments(&self, key: &str, limit: u32) -> Result<Vec<JiraComment>> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_comments = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_comments.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let url = format!(
"{}/rest/api/3/issue/{}/comment?orderBy=created&maxResults={}&startAt={}",
self.instance_url, key, page_size, start_at
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraCommentsResponse = response
.json()
.await
.context("Failed to parse comments response")?;
let page_count = resp.comments.len() as u32;
for c in resp.comments {
all_comments.push(JiraComment {
id: c.id,
author: c.author.and_then(|a| a.display_name).unwrap_or_default(),
body_adf: c.body,
created: c.created.unwrap_or_default(),
});
}
if page_count == 0 {
break;
}
let fetched = resp.start_at.saturating_add(page_count);
if fetched >= resp.total {
break;
}
start_at += page_count;
}
Ok(all_comments)
}
pub async fn add_comment(&self, key: &str, body_adf: &AdfDocument) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}/comment", self.instance_url, key);
let body = serde_json::json!({
"body": body_adf
});
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_worklogs(&self, key: &str, limit: u32) -> Result<JiraWorklogList> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let url = format!(
"{}/rest/api/3/issue/{}/worklog?maxResults={}",
self.instance_url,
key,
effective_limit.min(5000)
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraWorklogResponse = response
.json()
.await
.context("Failed to parse worklog response")?;
let worklogs: Vec<JiraWorklog> = resp
.worklogs
.into_iter()
.take(effective_limit as usize)
.map(|w| JiraWorklog {
id: w.id,
author: w.author.and_then(|a| a.display_name).unwrap_or_default(),
time_spent: w.time_spent.unwrap_or_default(),
time_spent_seconds: w.time_spent_seconds,
started: w.started.unwrap_or_default(),
comment: Self::extract_worklog_comment(w.comment.as_ref()),
})
.collect();
Ok(JiraWorklogList {
total: resp.total,
worklogs,
})
}
pub async fn add_worklog(
&self,
key: &str,
time_spent: &str,
started: Option<&str>,
comment: Option<&str>,
) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}/worklog", self.instance_url, key);
let mut body = serde_json::json!({
"timeSpent": time_spent,
});
if let Some(started) = started {
body["started"] = serde_json::Value::String(started.to_string());
}
if let Some(comment_text) = comment {
body["comment"] = serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": comment_text
}]
}]
});
}
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
fn extract_worklog_comment(adf_value: Option<&serde_json::Value>) -> Option<String> {
let adf_value = adf_value?;
let adf: AdfDocument = serde_json::from_value(adf_value.clone()).ok()?;
let md = adf_to_markdown(&adf).ok()?;
let trimmed = md.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub async fn get_transitions(&self, key: &str) -> Result<Vec<JiraTransition>> {
let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraTransitionsResponse = response
.json()
.await
.context("Failed to parse transitions response")?;
Ok(resp
.transitions
.into_iter()
.map(|t| JiraTransition {
id: t.id,
name: t.name,
})
.collect())
}
pub async fn do_transition(&self, key: &str, transition_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}/transitions", self.instance_url, key);
let body = serde_json::json!({
"transition": { "id": transition_id }
});
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn search_issues(&self, jql: &str, limit: u32) -> Result<JiraSearchResult> {
let url = format!("{}/rest/api/3/search/jql", self.instance_url);
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_issues = Vec::new();
let mut next_token: Option<String> = None;
loop {
let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let mut body = serde_json::json!({
"jql": jql,
"maxResults": page_size,
"fields": ["summary", "status", "issuetype", "assignee", "priority"]
});
if let Some(ref token) = next_token {
body["nextPageToken"] = serde_json::Value::String(token.clone());
}
let response = self
.post_json(&url, &body)
.await
.context("Failed to send search request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let page: JiraSearchResponse = response
.json()
.await
.context("Failed to parse JIRA search response")?;
let page_count = page.issues.len();
for r in page.issues {
all_issues.push(JiraIssue {
key: r.key,
summary: r.fields.summary.unwrap_or_default(),
description_adf: r.fields.description,
status: r.fields.status.and_then(|s| s.name),
issue_type: r.fields.issuetype.and_then(|t| t.name),
assignee: r.fields.assignee.and_then(|a| a.display_name),
priority: r.fields.priority.and_then(|p| p.name),
labels: r.fields.labels,
custom_fields: Vec::new(),
});
}
match page.next_page_token {
Some(token) if page_count > 0 => next_token = Some(token),
_ => break,
}
}
let total = all_issues.len() as u32;
Ok(JiraSearchResult {
issues: all_issues,
total,
})
}
pub async fn search_confluence(
&self,
cql: &str,
limit: u32,
) -> Result<ConfluenceSearchResults> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_results = Vec::new();
let mut start: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_results.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let base = format!("{}/wiki/rest/api/content/search", self.instance_url);
let url = reqwest::Url::parse_with_params(
&base,
&[
("cql", cql),
("limit", &page_size.to_string()),
("start", &start.to_string()),
("expand", "space"),
],
)
.context("Failed to build Confluence search URL")?;
let response = self.get_json(url.as_str()).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceContentSearchResponse = response
.json()
.await
.context("Failed to parse Confluence search response")?;
let page_count = resp.results.len() as u32;
for r in resp.results {
let space_key = r
.expandable
.and_then(|e| e.space)
.and_then(|s| s.rsplit('/').next().map(String::from))
.unwrap_or_default();
all_results.push(ConfluenceSearchResult {
id: r.id,
title: r.title,
space_key,
});
}
let has_next = resp.links.and_then(|l| l.next).is_some();
if !has_next || page_count == 0 {
break;
}
start += page_count;
}
let total = all_results.len() as u32;
Ok(ConfluenceSearchResults {
results: all_results,
total,
})
}
pub async fn search_confluence_users(
&self,
query: &str,
limit: u32,
) -> Result<ConfluenceUserSearchResults> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_results = Vec::new();
let mut start: u32 = 0;
let cql = format!("user.fullname~\"{query}\"");
loop {
let remaining = effective_limit.saturating_sub(all_results.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let base = format!("{}/wiki/rest/api/search/user", self.instance_url);
let url = reqwest::Url::parse_with_params(
&base,
&[
("cql", cql.as_str()),
("limit", &page_size.to_string()),
("start", &start.to_string()),
],
)
.context("Failed to build Confluence user search URL")?;
let response = self.get_json(url.as_str()).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceUserSearchResponse = response
.json()
.await
.context("Failed to parse Confluence user search response")?;
let page_count = resp.results.len() as u32;
for r in resp.results {
let Some(user) = r.user else {
continue;
};
let display_name = user.display_name.or(user.public_name).unwrap_or_default();
all_results.push(ConfluenceUserSearchResult {
account_id: user.account_id,
display_name,
email: user.email,
});
}
let has_next = resp.links.and_then(|l| l.next).is_some();
if !has_next || page_count == 0 {
break;
}
start += page_count;
}
let total = all_results.len() as u32;
Ok(ConfluenceUserSearchResults {
users: all_results,
total,
})
}
pub async fn get_boards(
&self,
project: Option<&str>,
board_type: Option<&str>,
limit: u32,
) -> Result<AgileBoardList> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_boards = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_boards.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let mut url = format!(
"{}/rest/agile/1.0/board?maxResults={}&startAt={}",
self.instance_url, page_size, start_at
);
if let Some(proj) = project {
url.push_str(&format!("&projectKeyOrId={proj}"));
}
if let Some(bt) = board_type {
url.push_str(&format!("&type={bt}"));
}
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: AgileBoardListResponse = response
.json()
.await
.context("Failed to parse board list response")?;
let page_count = resp.values.len() as u32;
for b in resp.values {
all_boards.push(AgileBoard {
id: b.id,
name: b.name,
board_type: b.board_type,
project_key: b.location.and_then(|l| l.project_key),
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
let total = all_boards.len() as u32;
Ok(AgileBoardList {
boards: all_boards,
total,
})
}
pub async fn get_board_issues(
&self,
board_id: u64,
jql: Option<&str>,
limit: u32,
) -> Result<JiraSearchResult> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_issues = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let base = format!(
"{}/rest/agile/1.0/board/{}/issue",
self.instance_url, board_id
);
let mut params: Vec<(&str, String)> = vec![
("maxResults", page_size.to_string()),
("startAt", start_at.to_string()),
];
if let Some(jql_str) = jql {
params.push(("jql", jql_str.to_string()));
}
let url = reqwest::Url::parse_with_params(
&base,
params.iter().map(|(k, v)| (*k, v.as_str())),
)
.context("Failed to build board issues URL")?;
let response = self.get_json(url.as_str()).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: AgileIssueListResponse = response
.json()
.await
.context("Failed to parse board issues response")?;
let page_count = resp.issues.len() as u32;
for r in resp.issues {
all_issues.push(JiraIssue {
key: r.key,
summary: r.fields.summary.unwrap_or_default(),
description_adf: r.fields.description,
status: r.fields.status.and_then(|s| s.name),
issue_type: r.fields.issuetype.and_then(|t| t.name),
assignee: r.fields.assignee.and_then(|a| a.display_name),
priority: r.fields.priority.and_then(|p| p.name),
labels: r.fields.labels,
custom_fields: Vec::new(),
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
let total = all_issues.len() as u32;
Ok(JiraSearchResult {
issues: all_issues,
total,
})
}
pub async fn get_sprints(
&self,
board_id: u64,
state: Option<&str>,
limit: u32,
) -> Result<AgileSprintList> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_sprints = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_sprints.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let mut url = format!(
"{}/rest/agile/1.0/board/{}/sprint?maxResults={}&startAt={}",
self.instance_url, board_id, page_size, start_at
);
if let Some(s) = state {
url.push_str(&format!("&state={s}"));
}
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: AgileSprintListResponse = response
.json()
.await
.context("Failed to parse sprint list response")?;
let page_count = resp.values.len() as u32;
for s in resp.values {
all_sprints.push(AgileSprint {
id: s.id,
name: s.name,
state: s.state,
start_date: s.start_date,
end_date: s.end_date,
goal: s.goal,
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
let total = all_sprints.len() as u32;
Ok(AgileSprintList {
sprints: all_sprints,
total,
})
}
pub async fn get_sprint_issues(
&self,
sprint_id: u64,
jql: Option<&str>,
limit: u32,
) -> Result<JiraSearchResult> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_issues = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_issues.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let base = format!(
"{}/rest/agile/1.0/sprint/{}/issue",
self.instance_url, sprint_id
);
let mut params: Vec<(&str, String)> = vec![
("maxResults", page_size.to_string()),
("startAt", start_at.to_string()),
];
if let Some(jql_str) = jql {
params.push(("jql", jql_str.to_string()));
}
let url = reqwest::Url::parse_with_params(
&base,
params.iter().map(|(k, v)| (*k, v.as_str())),
)
.context("Failed to build sprint issues URL")?;
let response = self.get_json(url.as_str()).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: AgileIssueListResponse = response
.json()
.await
.context("Failed to parse sprint issues response")?;
let page_count = resp.issues.len() as u32;
for r in resp.issues {
all_issues.push(JiraIssue {
key: r.key,
summary: r.fields.summary.unwrap_or_default(),
description_adf: r.fields.description,
status: r.fields.status.and_then(|s| s.name),
issue_type: r.fields.issuetype.and_then(|t| t.name),
assignee: r.fields.assignee.and_then(|a| a.display_name),
priority: r.fields.priority.and_then(|p| p.name),
labels: r.fields.labels,
custom_fields: Vec::new(),
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
let total = all_issues.len() as u32;
Ok(JiraSearchResult {
issues: all_issues,
total,
})
}
pub async fn add_issues_to_sprint(&self, sprint_id: u64, issue_keys: &[&str]) -> Result<()> {
let url = format!(
"{}/rest/agile/1.0/sprint/{}/issue",
self.instance_url, sprint_id
);
let body = serde_json::json!({ "issues": issue_keys });
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn create_sprint(
&self,
board_id: u64,
name: &str,
start_date: Option<&str>,
end_date: Option<&str>,
goal: Option<&str>,
) -> Result<AgileSprint> {
let url = format!("{}/rest/agile/1.0/sprint", self.instance_url);
let mut body = serde_json::json!({
"originBoardId": board_id,
"name": name
});
if let Some(sd) = start_date {
body["startDate"] = serde_json::Value::String(sd.to_string());
}
if let Some(ed) = end_date {
body["endDate"] = serde_json::Value::String(ed.to_string());
}
if let Some(g) = goal {
body["goal"] = serde_json::Value::String(g.to_string());
}
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let entry: AgileSprintEntry = response
.json()
.await
.context("Failed to parse sprint create response")?;
Ok(AgileSprint {
id: entry.id,
name: entry.name,
state: entry.state,
start_date: entry.start_date,
end_date: entry.end_date,
goal: entry.goal,
})
}
pub async fn update_sprint(
&self,
sprint_id: u64,
name: Option<&str>,
state: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
goal: Option<&str>,
) -> Result<()> {
let url = format!("{}/rest/agile/1.0/sprint/{}", self.instance_url, sprint_id);
let mut body = serde_json::Map::new();
if let Some(n) = name {
body.insert("name".to_string(), serde_json::Value::String(n.to_string()));
}
if let Some(s) = state {
body.insert(
"state".to_string(),
serde_json::Value::String(s.to_string()),
);
}
if let Some(sd) = start_date {
body.insert(
"startDate".to_string(),
serde_json::Value::String(sd.to_string()),
);
}
if let Some(ed) = end_date {
body.insert(
"endDate".to_string(),
serde_json::Value::String(ed.to_string()),
);
}
if let Some(g) = goal {
body.insert("goal".to_string(), serde_json::Value::String(g.to_string()));
}
let response = self
.put_json(&url, &serde_json::Value::Object(body))
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_issue_links(&self, key: &str) -> Result<Vec<JiraIssueLink>> {
let url = format!(
"{}/rest/api/3/issue/{}?fields=issuelinks",
self.instance_url, key
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraIssueLinksResponse = response
.json()
.await
.context("Failed to parse issue links response")?;
let mut links = Vec::new();
for entry in resp.fields.issuelinks {
if let Some(inward) = entry.inward_issue {
links.push(JiraIssueLink {
id: entry.id.clone(),
link_type: entry.link_type.name.clone(),
direction: "inward".to_string(),
linked_issue_key: inward.key,
linked_issue_summary: inward.fields.and_then(|f| f.summary).unwrap_or_default(),
});
}
if let Some(outward) = entry.outward_issue {
links.push(JiraIssueLink {
id: entry.id,
link_type: entry.link_type.name,
direction: "outward".to_string(),
linked_issue_key: outward.key,
linked_issue_summary: outward
.fields
.and_then(|f| f.summary)
.unwrap_or_default(),
});
}
}
Ok(links)
}
pub async fn get_link_types(&self) -> Result<Vec<JiraLinkType>> {
let url = format!("{}/rest/api/3/issueLinkType", self.instance_url);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraLinkTypesResponse = response
.json()
.await
.context("Failed to parse link types response")?;
Ok(resp
.issue_link_types
.into_iter()
.map(|t| JiraLinkType {
id: t.id,
name: t.name,
inward: t.inward,
outward: t.outward,
})
.collect())
}
pub async fn create_issue_link(
&self,
type_name: &str,
inward_key: &str,
outward_key: &str,
) -> Result<()> {
let url = format!("{}/rest/api/3/issueLink", self.instance_url);
let body = serde_json::json!({"type": {"name": type_name}, "inwardIssue": {"key": inward_key}, "outwardIssue": {"key": outward_key}});
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn remove_issue_link(&self, link_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issueLink/{}", self.instance_url, link_id);
let response = self.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn link_to_epic(&self, epic_key: &str, issue_key: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}", self.instance_url, issue_key);
let body = serde_json::json!({"fields": {"parent": {"key": epic_key}}});
let response = self.put_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_issue_id(&self, key: &str) -> Result<String> {
let url = format!("{}/rest/api/3/issue/{}?fields=", self.instance_url, key);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraIssueIdResponse = response
.json()
.await
.context("Failed to parse issue ID response")?;
Ok(resp.id)
}
pub async fn get_dev_status_summary(&self, key: &str) -> Result<JiraDevStatusSummary> {
let issue_id = self.get_issue_id(key).await?;
let url = format!(
"{}/rest/dev-status/1.0/issue/summary?issueId={}",
self.instance_url, issue_id
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: DevStatusSummaryResponse = response
.json()
.await
.context("Failed to parse DevStatus summary response")?;
fn extract_count(cat: Option<DevStatusSummaryCategory>) -> JiraDevStatusCount {
match cat {
Some(c) => JiraDevStatusCount {
count: c.overall.map_or(0, |o| o.count),
providers: c
.by_instance_type
.into_values()
.map(|i| i.name)
.filter(|n| !n.is_empty())
.collect(),
},
None => JiraDevStatusCount {
count: 0,
providers: Vec::new(),
},
}
}
Ok(JiraDevStatusSummary {
pullrequest: extract_count(resp.summary.pullrequest),
branch: extract_count(resp.summary.branch),
repository: extract_count(resp.summary.repository),
})
}
pub async fn get_dev_status(
&self,
key: &str,
data_type: Option<&str>,
application_type: Option<&str>,
) -> Result<JiraDevStatus> {
let issue_id = self.get_issue_id(key).await?;
let app_types: Vec<String> = if let Some(app) = application_type {
vec![app.to_string()]
} else {
let summary = self.get_dev_status_summary(key).await?;
let mut providers: Vec<String> = Vec::new();
for p in summary
.pullrequest
.providers
.into_iter()
.chain(summary.branch.providers)
.chain(summary.repository.providers)
{
if !providers.contains(&p) {
providers.push(p);
}
}
if providers.is_empty() {
providers.push("GitHub".to_string());
}
providers
};
let data_types: Vec<&str> = match data_type {
Some(dt) => vec![dt],
None => vec!["pullrequest", "branch", "repository"],
};
let mut status = JiraDevStatus {
pull_requests: Vec::new(),
branches: Vec::new(),
repositories: Vec::new(),
};
for app in &app_types {
for dt in &data_types {
let url = format!(
"{}/rest/dev-status/1.0/issue/detail?issueId={}&applicationType={}&dataType={}",
self.instance_url, issue_id, app, dt
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let http_status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed {
status: http_status,
body,
}
.into());
}
let resp: DevStatusResponse = response
.json()
.await
.context("Failed to parse DevStatus response")?;
for detail in resp.detail {
for pr in detail.pull_requests {
status.pull_requests.push(JiraDevPullRequest {
id: pr.id,
name: pr.name,
status: pr.status,
url: pr.url,
repository_name: pr.repository_name,
source_branch: pr.source.map(|s| s.branch).unwrap_or_default(),
destination_branch: pr
.destination
.map(|d| d.branch)
.unwrap_or_default(),
author: pr.author.map(|a| a.name),
reviewers: pr.reviewers.into_iter().map(|r| r.name).collect(),
comment_count: pr.comment_count,
last_update: pr.last_update,
});
}
for branch in detail.branches {
status.branches.push(JiraDevBranch {
name: branch.name,
url: branch.url,
repository_name: branch.repository_name,
create_pr_url: branch.create_pr_url,
last_commit: branch.last_commit.map(Self::convert_commit),
});
}
for repo in detail.repositories {
status.repositories.push(JiraDevRepository {
name: repo.name,
url: repo.url,
commits: repo.commits.into_iter().map(Self::convert_commit).collect(),
});
}
}
}
}
Ok(status)
}
fn convert_commit(c: DevStatusCommit) -> JiraDevCommit {
JiraDevCommit {
id: c.id,
display_id: c.display_id,
message: c.message,
author: c.author.map(|a| a.name),
timestamp: c.author_timestamp,
url: c.url,
file_count: c.file_count,
merge: c.merge,
}
}
pub async fn get_attachments(&self, key: &str) -> Result<Vec<JiraAttachment>> {
let url = format!(
"{}/rest/api/3/issue/{}?fields=attachment",
self.instance_url, key
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraAttachmentIssueResponse = response
.json()
.await
.context("Failed to parse attachment response")?;
Ok(resp
.fields
.attachment
.into_iter()
.map(|a| JiraAttachment {
id: a.id,
filename: a.filename,
mime_type: a.mime_type,
size: a.size,
content_url: a.content,
})
.collect())
}
pub async fn get_changelog(&self, key: &str, limit: u32) -> Result<Vec<JiraChangelogEntry>> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_entries = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_entries.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let url = format!(
"{}/rest/api/3/issue/{}/changelog?maxResults={}&startAt={}",
self.instance_url, key, page_size, start_at
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraChangelogResponse = response
.json()
.await
.context("Failed to parse changelog response")?;
let page_count = resp.values.len() as u32;
for e in resp.values {
all_entries.push(JiraChangelogEntry {
id: e.id,
author: e.author.and_then(|a| a.display_name).unwrap_or_default(),
created: e.created.unwrap_or_default(),
items: e
.items
.into_iter()
.map(|i| JiraChangelogItem {
field: i.field,
from_string: i.from_string,
to_string: i.to_string,
})
.collect(),
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
Ok(all_entries)
}
pub async fn get_fields(&self) -> Result<Vec<JiraField>> {
let url = format!("{}/rest/api/3/field", self.instance_url);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let entries: Vec<JiraFieldEntry> = response
.json()
.await
.context("Failed to parse field list response")?;
Ok(entries
.into_iter()
.map(|f| JiraField {
id: f.id,
name: f.name,
custom: f.custom,
schema_type: f.schema.and_then(|s| s.schema_type),
})
.collect())
}
pub async fn get_field_contexts(&self, field_id: &str) -> Result<Vec<String>> {
let url = format!(
"{}/rest/api/3/field/{}/context",
self.instance_url, field_id
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraFieldContextsResponse = response
.json()
.await
.context("Failed to parse field contexts response")?;
Ok(resp.values.into_iter().map(|c| c.id).collect())
}
pub async fn get_field_options(
&self,
field_id: &str,
context_id: Option<&str>,
) -> Result<Vec<JiraFieldOption>> {
let ctx = if let Some(id) = context_id {
id.to_string()
} else {
let contexts = self.get_field_contexts(field_id).await?;
contexts.into_iter().next().ok_or_else(|| {
anyhow::anyhow!(
"No contexts found for field \"{field_id}\". \
Use --context-id to specify one explicitly."
)
})?
};
let url = format!(
"{}/rest/api/3/field/{}/context/{}/option",
self.instance_url, field_id, ctx
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraFieldOptionsResponse = response
.json()
.await
.context("Failed to parse field options response")?;
Ok(resp
.values
.into_iter()
.map(|o| JiraFieldOption {
id: o.id,
value: o.value,
})
.collect())
}
pub async fn get_projects(&self, limit: u32) -> Result<JiraProjectList> {
let effective_limit = if limit == 0 { u32::MAX } else { limit };
let mut all_projects = Vec::new();
let mut start_at: u32 = 0;
loop {
let remaining = effective_limit.saturating_sub(all_projects.len() as u32);
if remaining == 0 {
break;
}
let page_size = remaining.min(PAGE_SIZE);
let url = format!(
"{}/rest/api/3/project/search?maxResults={}&startAt={}",
self.instance_url, page_size, start_at
);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: JiraProjectSearchResponse = response
.json()
.await
.context("Failed to parse project search response")?;
let page_count = resp.values.len() as u32;
for p in resp.values {
all_projects.push(JiraProject {
id: p.id,
key: p.key,
name: p.name,
project_type: p.project_type_key,
lead: p.lead.and_then(|l| l.display_name),
});
}
if resp.is_last || page_count == 0 {
break;
}
start_at += page_count;
}
let total = all_projects.len() as u32;
Ok(JiraProjectList {
projects: all_projects,
total,
})
}
pub async fn delete_issue(&self, key: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}", self.instance_url, key);
let response = self.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_watchers(&self, key: &str) -> Result<JiraWatcherList> {
let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
let response = self.get_json(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let json: serde_json::Value = response
.json()
.await
.context("Failed to parse watchers response")?;
let watch_count = json["watchCount"].as_u64().unwrap_or(0) as u32;
let watchers = json["watchers"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| serde_json::from_value::<JiraUser>(v.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(JiraWatcherList {
watchers,
watch_count,
})
}
pub async fn add_watcher(&self, key: &str, account_id: &str) -> Result<()> {
let url = format!("{}/rest/api/3/issue/{}/watchers", self.instance_url, key);
let body = serde_json::json!(account_id);
let response = self.post_json(&url, &body).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn remove_watcher(&self, key: &str, account_id: &str) -> Result<()> {
let url = format!(
"{}/rest/api/3/issue/{}/watchers?accountId={}",
self.instance_url, key, account_id
);
let response = self.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_myself(&self) -> Result<JiraUser> {
let url = format!("{}/rest/api/3/myself", self.instance_url);
let response = self
.client
.get(&url)
.header("Authorization", &self.auth_header)
.header("Accept", "application/json")
.send()
.await
.context("Failed to send request to JIRA API")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
response
.json()
.await
.context("Failed to parse user response")
}
}