1use crate::convert::convert_jira_to_markdown;
16use anyhow::{bail, Context, Result};
17use serde::Deserialize;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21fn http_client() -> Result<reqwest::blocking::Client> {
22 reqwest::blocking::Client::builder()
23 .timeout(Duration::from_secs(30))
24 .connect_timeout(Duration::from_secs(5))
25 .build()
26 .context("build HTTP client")
27}
28
29pub fn default_import_dir() -> PathBuf {
31 PathBuf::from("corpus/imports")
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum JiraAuth {
37 Basic { user: String, token: String },
39 Header(String),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct JiraConfig {
47 pub base_url: String,
48 pub auth: JiraAuth,
49}
50
51impl JiraConfig {
52 pub fn new(base_url: impl Into<String>, auth: JiraAuth) -> Self {
53 Self {
54 base_url: base_url.into(),
55 auth,
56 }
57 }
58
59 pub fn from_env() -> Result<Self> {
62 let base_url = std::env::var("JIRA_BASE_URL")
63 .context("JIRA_BASE_URL is not set (e.g. https://your-instance.atlassian.net)")?;
64 if let Ok(header) = std::env::var("JIRA_AUTH_HEADER") {
65 return Ok(Self::new(base_url, JiraAuth::Header(header)));
66 }
67 let user = std::env::var("JIRA_EMAIL")
68 .context("set JIRA_AUTH_HEADER, or JIRA_EMAIL + JIRA_API_TOKEN")?;
69 let token = std::env::var("JIRA_API_TOKEN").context("JIRA_API_TOKEN is not set")?;
70 Ok(Self::new(base_url, JiraAuth::Basic { user, token }))
71 }
72}
73
74fn apply_auth(
75 req: reqwest::blocking::RequestBuilder,
76 auth: &JiraAuth,
77) -> reqwest::blocking::RequestBuilder {
78 match auth {
79 JiraAuth::Basic { user, token } => req.basic_auth(user, Some(token)),
80 JiraAuth::Header(value) => req.header(reqwest::header::AUTHORIZATION, value),
81 }
82}
83
84#[derive(Debug, Deserialize)]
85struct SearchResponse {
86 #[serde(rename = "startAt")]
87 start_at: u32,
88 total: u32,
89 issues: Vec<SearchIssue>,
90}
91
92#[derive(Debug, Deserialize)]
93struct SearchIssue {
94 key: String,
95 fields: SearchFields,
96}
97
98#[derive(Debug, Deserialize)]
99struct SearchFields {
100 summary: String,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct IssueSummary {
106 pub key: String,
107 pub summary: String,
108}
109
110fn parse_search_response(json: &str) -> Result<(Vec<IssueSummary>, u32, u32)> {
111 let parsed: SearchResponse =
112 serde_json::from_str(json).context("parse Jira search response")?;
113 let issues = parsed
114 .issues
115 .into_iter()
116 .map(|i| IssueSummary {
117 key: i.key,
118 summary: i.fields.summary,
119 })
120 .collect();
121 Ok((issues, parsed.start_at, parsed.total))
122}
123
124#[derive(Debug, Deserialize)]
125struct CommentsResponse {
126 comments: Vec<RawComment>,
127}
128
129#[derive(Debug, Deserialize)]
130struct RawComment {
131 author: RawUser,
132 body: serde_json::Value,
134 created: String,
135}
136
137#[derive(Debug, Deserialize)]
138struct RawUser {
139 #[serde(rename = "displayName")]
140 display_name: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct IssueComment {
146 pub author: String,
147 pub created: String,
148 pub body_wiki: String,
149}
150
151pub fn comment_body_to_text(body: &serde_json::Value) -> String {
154 match body {
155 serde_json::Value::String(s) => s.clone(),
156 serde_json::Value::Object(_) | serde_json::Value::Array(_) => adf_to_plain_text(body),
157 other => other.to_string(),
158 }
159}
160
161fn adf_to_plain_text(node: &serde_json::Value) -> String {
162 let mut out = String::new();
163 adf_walk(node, &mut out);
164 out.split_whitespace()
165 .collect::<Vec<_>>()
166 .join(" ")
167 .trim()
168 .to_string()
169}
170
171fn adf_walk(node: &serde_json::Value, out: &mut String) {
172 match node {
173 serde_json::Value::Object(map) => {
174 if let Some(serde_json::Value::String(text)) = map.get("text") {
175 if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
176 out.push(' ');
177 }
178 out.push_str(text);
179 }
180 if let Some(children) = map.get("content").and_then(|c| c.as_array()) {
181 for child in children {
182 adf_walk(child, out);
183 }
184 if matches!(map.get("type").and_then(|t| t.as_str()), Some("paragraph" | "heading")) {
186 out.push('\n');
187 }
188 }
189 }
190 serde_json::Value::Array(items) => {
191 for item in items {
192 adf_walk(item, out);
193 }
194 }
195 _ => {}
196 }
197}
198
199pub fn plain_text_to_adf(text: &str) -> serde_json::Value {
204 let paragraphs: Vec<serde_json::Value> = text
205 .split("\n")
206 .map(|line| line.trim_end())
207 .filter(|line| !line.is_empty() || text.contains('\n'))
208 .map(|line| {
209 serde_json::json!({
210 "type": "paragraph",
211 "content": if line.is_empty() {
212 Vec::<serde_json::Value>::new()
213 } else {
214 vec![serde_json::json!({"type": "text", "text": line})]
215 }
216 })
217 })
218 .collect();
219 let content = if paragraphs.is_empty() {
220 vec![serde_json::json!({
221 "type": "paragraph",
222 "content": [{"type": "text", "text": text}]
223 })]
224 } else {
225 paragraphs
226 };
227 serde_json::json!({
228 "type": "doc",
229 "version": 1,
230 "content": content
231 })
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum CommentBodyFormat {
237 Wiki,
239 Adf,
241 #[default]
243 Auto,
244}
245
246impl CommentBodyFormat {
247 pub fn resolve(self, base_url: &str) -> CommentBodyFormat {
248 match self {
249 CommentBodyFormat::Auto => {
250 if is_cloud_host(base_url) {
251 CommentBodyFormat::Adf
252 } else {
253 CommentBodyFormat::Wiki
254 }
255 }
256 other => other,
257 }
258 }
259}
260
261pub fn is_cloud_host(base_url: &str) -> bool {
263 let lower = base_url.to_ascii_lowercase();
264 lower.contains("atlassian.net") || lower.contains("jira.com")
265}
266
267fn parse_comments_response(json: &str) -> Result<Vec<IssueComment>> {
268 let parsed: CommentsResponse =
269 serde_json::from_str(json).context("parse Jira comments response")?;
270 Ok(parsed
271 .comments
272 .into_iter()
273 .map(|c| IssueComment {
274 author: c.author.display_name,
275 created: c.created,
276 body_wiki: comment_body_to_text(&c.body),
277 })
278 .collect())
279}
280
281fn search_url_candidates(base_url: &str) -> Vec<String> {
283 let base = base_url.trim_end_matches('/');
284 let mut urls = vec![format!("{base}/rest/api/2/search")];
285 if is_cloud_host(base_url) {
286 urls.push(format!("{base}/rest/api/3/search"));
287 urls.push(format!("{base}/rest/api/3/search/jql"));
288 } else {
289 urls.push(format!("{base}/rest/api/3/search"));
291 urls.push(format!("{base}/rest/api/3/search/jql"));
292 }
293 urls
294}
295
296fn build_search_request_at(
297 client: &reqwest::blocking::Client,
298 cfg: &JiraConfig,
299 url: &str,
300 jql: &str,
301 start_at: u32,
302 max_results: u32,
303) -> reqwest::Result<reqwest::blocking::Request> {
304 let start_at_s = start_at.to_string();
305 let max_results_s = max_results.to_string();
306 let req = client.get(url).query(&[
307 ("jql", jql),
308 ("startAt", start_at_s.as_str()),
309 ("maxResults", max_results_s.as_str()),
310 ("fields", "summary"),
311 ]);
312 apply_auth(req, &cfg.auth).build()
313}
314
315fn build_comments_request_at(
316 client: &reqwest::blocking::Client,
317 cfg: &JiraConfig,
318 issue_key: &str,
319 api: &str,
320) -> reqwest::Result<reqwest::blocking::Request> {
321 let url = format!(
322 "{}/rest/api/{api}/issue/{issue_key}/comment",
323 cfg.base_url.trim_end_matches('/')
324 );
325 apply_auth(client.get(&url), &cfg.auth).build()
326}
327
328fn search_page(
330 client: &reqwest::blocking::Client,
331 cfg: &JiraConfig,
332 jql: &str,
333 start_at: u32,
334 page_size: u32,
335) -> Result<(Vec<IssueSummary>, u32, u32, String)> {
336 let mut last_err = None;
337 for url in search_url_candidates(&cfg.base_url) {
338 let req = match build_search_request_at(client, cfg, &url, jql, start_at, page_size) {
339 Ok(r) => r,
340 Err(e) => {
341 last_err = Some(anyhow::anyhow!("build search {url}: {e}"));
342 continue;
343 }
344 };
345 let resp = match client.execute(req) {
346 Ok(r) => r,
347 Err(e) => {
348 last_err = Some(anyhow::anyhow!("send search {url}: {e}"));
349 continue;
350 }
351 };
352 if !resp.status().is_success() {
353 last_err = Some(anyhow::anyhow!(
354 "Jira search HTTP {} at {url}",
355 resp.status()
356 ));
357 if matches!(resp.status().as_u16(), 400 | 404 | 405 | 410) {
359 continue;
360 }
361 bail!(last_err.unwrap());
362 }
363 let body = resp.text().context("read Jira search response")?;
364 let (issues, start, total) = parse_search_response(&body)?;
365 return Ok((issues, start, total, url));
366 }
367 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Jira search failed on all free endpoints")))
368}
369
370pub fn search_all_issues(
374 cfg: &JiraConfig,
375 jql: &str,
376 page_size: u32,
377) -> Result<Vec<IssueSummary>> {
378 let client = http_client()?;
379 let mut out = Vec::new();
380 let mut start_at = 0u32;
381 loop {
382 let (mut issues, returned_start, total, _url) =
383 search_page(&client, cfg, jql, start_at, page_size)?;
384 let got = issues.len() as u32;
385 out.append(&mut issues);
386 if got == 0 || returned_start + got >= total {
387 break;
388 }
389 start_at = returned_start + got;
390 }
391 Ok(out)
392}
393
394pub fn fetch_comments(cfg: &JiraConfig, issue_key: &str) -> Result<Vec<IssueComment>> {
396 let client = http_client()?;
397 let mut last_status = None;
399 for api in ["2", "3"] {
400 let req = build_comments_request_at(&client, cfg, issue_key, api)
401 .with_context(|| format!("build comments request for {issue_key} api/{api}"))?;
402 let resp = client
403 .execute(req)
404 .with_context(|| format!("fetch comments for {issue_key}"))?;
405 if resp.status().is_success() {
406 let body = resp.text().context("read Jira comments response")?;
407 return parse_comments_response(&body);
408 }
409 last_status = Some(resp.status());
410 if !matches!(resp.status().as_u16(), 404 | 410 | 405) {
411 break;
412 }
413 }
414 bail!(
415 "fetching comments for {issue_key} failed: HTTP {}",
416 last_status
417 .map(|s| s.to_string())
418 .unwrap_or_else(|| "unknown".into())
419 )
420}
421
422pub fn slugify(title: &str) -> String {
425 let mut slug = String::new();
426 let mut last_dash = true; for c in title.to_lowercase().chars() {
428 if c.is_ascii_alphanumeric() {
429 slug.push(c);
430 last_dash = false;
431 } else if !last_dash {
432 slug.push('-');
433 last_dash = true;
434 }
435 }
436 while slug.ends_with('-') {
437 slug.pop();
438 }
439 if slug.is_empty() {
440 "topic".into()
441 } else {
442 slug
443 }
444}
445
446pub fn draft_markdown(
449 id: &str,
450 issue_key: &str,
451 title: &str,
452 comments: &[(String, String, String)],
453) -> String {
454 let mut out = format!(
455 "---\nid: {id}\ntitle: {title}\nprefix: snell\ntags: []\nsop: none\n---\n\n# {title}\n\n<!-- imported from {issue_key}; edit into a real answer, then move to corpus/responses/ -->\n"
456 );
457 for (author, created, body_md) in comments {
458 out.push_str(&format!(
459 "\n## Comment by {author} ({created})\n\n{}\n",
460 body_md.trim()
461 ));
462 }
463 out
464}
465
466pub fn import_jira(
471 cfg: &JiraConfig,
472 jql: &str,
473 out_dir: &Path,
474 max_results: u32,
475 dry_run: bool,
476) -> Result<Vec<PathBuf>> {
477 let issues = search_all_issues(cfg, jql, max_results)?;
478 if !dry_run {
479 std::fs::create_dir_all(out_dir)
480 .with_context(|| format!("create {}", out_dir.display()))?;
481 }
482 let mut written = Vec::new();
483 for issue in issues {
484 let id = format!(
485 "snell-{}-{}",
486 slugify(&issue.summary),
487 issue.key.to_lowercase()
488 );
489 let path = out_dir.join(format!("{id}.md"));
490 if dry_run {
491 written.push(path);
492 continue;
493 }
494 let comments = fetch_comments(cfg, &issue.key)?;
495 let rendered = comments
496 .into_iter()
497 .map(|c| -> Result<(String, String, String)> {
498 let md = convert_jira_to_markdown(&c.body_wiki)?;
499 Ok((c.author, c.created, md))
500 })
501 .collect::<Result<Vec<_>>>()?;
502 let text = draft_markdown(&id, &issue.key, &issue.summary, &rendered);
503 std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
504 written.push(path);
505 }
506 Ok(written)
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct JiraProbe {
512 pub base_url: String,
513 pub display_name: String,
514 pub account: String,
515 pub server_title: Option<String>,
516 pub server_version: Option<String>,
517 pub cloud_host: bool,
519 pub comment_format: CommentBodyFormat,
521}
522
523#[derive(Debug, Deserialize)]
524struct MyselfResponse {
525 #[serde(default)]
526 name: Option<String>,
527 #[serde(rename = "displayName", default)]
528 display_name: Option<String>,
529 #[serde(rename = "accountId", default)]
530 account_id: Option<String>,
531 #[serde(rename = "emailAddress", default)]
532 email: Option<String>,
533}
534
535fn parse_myself_response(json: &str) -> Result<(String, String)> {
536 let parsed: MyselfResponse =
537 serde_json::from_str(json).context("parse Jira /myself response")?;
538 let display = parsed
539 .display_name
540 .or_else(|| parsed.name.clone())
541 .or_else(|| parsed.email.clone())
542 .unwrap_or_else(|| "(unknown)".into());
543 let account = parsed
544 .account_id
545 .or(parsed.name)
546 .or(parsed.email)
547 .unwrap_or_else(|| "(unknown)".into());
548 Ok((display, account))
549}
550
551#[derive(Debug, Deserialize)]
552struct ServerInfoResponse {
553 #[serde(rename = "serverTitle", default)]
554 server_title: Option<String>,
555 #[serde(default)]
556 version: Option<String>,
557}
558
559fn parse_server_info_response(json: &str) -> Result<(Option<String>, Option<String>)> {
560 let parsed: ServerInfoResponse =
561 serde_json::from_str(json).context("parse Jira /serverInfo response")?;
562 Ok((parsed.server_title, parsed.version))
563}
564
565fn build_myself_request(
566 client: &reqwest::blocking::Client,
567 cfg: &JiraConfig,
568) -> reqwest::Result<reqwest::blocking::Request> {
569 let url = format!("{}/rest/api/2/myself", cfg.base_url.trim_end_matches('/'));
570 apply_auth(client.get(&url), &cfg.auth).build()
571}
572
573fn build_server_info_request(
574 client: &reqwest::blocking::Client,
575 cfg: &JiraConfig,
576) -> reqwest::Result<reqwest::blocking::Request> {
577 let url = format!(
578 "{}/rest/api/2/serverInfo",
579 cfg.base_url.trim_end_matches('/')
580 );
581 apply_auth(client.get(&url), &cfg.auth).build()
582}
583
584pub fn probe_jira(cfg: &JiraConfig) -> Result<JiraProbe> {
589 let client = http_client()?;
590 let req = build_myself_request(&client, cfg).context("build /myself request")?;
591 let resp = client.execute(req).context("send /myself request")?;
592 if !resp.status().is_success() {
593 bail!(
594 "Jira probe failed (auth or reachability): HTTP {} on /rest/api/2/myself — free Cloud needs email+API token; Server/DC may use JIRA_AUTH_HEADER",
595 resp.status()
596 );
597 }
598 let body = resp.text().context("read /myself body")?;
599 let (display_name, account) = parse_myself_response(&body)?;
600
601 let mut server_title = None;
602 let mut server_version = None;
603 if let Ok(req) = build_server_info_request(&client, cfg) {
604 if let Ok(resp) = client.execute(req) {
605 if resp.status().is_success() {
606 if let Ok(text) = resp.text() {
607 if let Ok((t, v)) = parse_server_info_response(&text) {
608 server_title = t;
609 server_version = v;
610 }
611 }
612 }
613 }
614 }
615
616 let base = cfg.base_url.trim_end_matches('/').to_string();
617 let cloud = is_cloud_host(&base);
618 Ok(JiraProbe {
619 base_url: base.clone(),
620 display_name,
621 account,
622 server_title,
623 server_version,
624 cloud_host: cloud,
625 comment_format: CommentBodyFormat::Auto.resolve(&base),
626 })
627}
628
629pub fn format_probe(probe: &JiraProbe) -> String {
631 let host = if probe.cloud_host {
632 "Cloud Free/Standard host (platform REST)"
633 } else {
634 "Server/Data Center-style host (platform REST)"
635 };
636 let write = match probe.comment_format {
637 CommentBodyFormat::Adf => "POST /rest/api/3/issue/{key}/comment (ADF body)",
638 CommentBodyFormat::Wiki => "POST /rest/api/2/issue/{key}/comment (wiki body)",
639 CommentBodyFormat::Auto => "auto",
640 };
641 let mut lines = vec![
642 format!("jira: ok — free REST platform API (no Marketplace apps)"),
643 format!(" base: {}", probe.base_url),
644 format!(" host: {host}"),
645 format!(" user: {} ({})", probe.display_name, probe.account),
646 format!(" comment write: {write}"),
647 ];
648 if let Some(ref t) = probe.server_title {
649 lines.push(format!(" server: {t}"));
650 }
651 if let Some(ref v) = probe.server_version {
652 lines.push(format!(" version: {v}"));
653 }
654 lines.push(
655 " note: write is explicit one-shot comment only; bulk library sync stays human-gated"
656 .into(),
657 );
658 lines.join("\n") + "\n"
659}
660
661#[derive(Debug, Clone, PartialEq, Eq)]
663pub struct PostedComment {
664 pub issue_key: String,
665 pub comment_id: String,
666 pub body_wiki: String,
667}
668
669#[derive(Debug, Deserialize)]
670struct CommentCreateResponse {
671 id: Option<String>,
672 #[serde(default)]
673 body: Option<String>,
674}
675
676fn parse_comment_create_response(json: &str) -> Result<(String, Option<String>)> {
677 let parsed: CommentCreateResponse =
678 serde_json::from_str(json).context("parse Jira comment create response")?;
679 let id = parsed
680 .id
681 .filter(|s| !s.is_empty())
682 .unwrap_or_else(|| "(unknown)".into());
683 Ok((id, parsed.body))
684}
685
686fn comment_post_url(base_url: &str, issue_key: &str, format: CommentBodyFormat) -> String {
696 let base = base_url.trim_end_matches('/');
697 let api = match format.resolve(base_url) {
698 CommentBodyFormat::Adf => "3",
699 _ => "2",
700 };
701 format!("{base}/rest/api/{api}/issue/{issue_key}/comment")
702}
703
704fn comment_post_payload(body_wiki: &str, format: CommentBodyFormat, base_url: &str) -> serde_json::Value {
705 match format.resolve(base_url) {
706 CommentBodyFormat::Adf => serde_json::json!({ "body": plain_text_to_adf(body_wiki) }),
707 _ => serde_json::json!({ "body": body_wiki }),
708 }
709}
710
711fn build_comment_post_request(
712 client: &reqwest::blocking::Client,
713 cfg: &JiraConfig,
714 issue_key: &str,
715 body_wiki: &str,
716 format: CommentBodyFormat,
717) -> reqwest::Result<reqwest::blocking::Request> {
718 let url = comment_post_url(&cfg.base_url, issue_key, format);
719 let payload = comment_post_payload(body_wiki, format, &cfg.base_url);
720 apply_auth(client.post(&url).json(&payload), &cfg.auth).build()
721}
722
723pub fn post_issue_comment(
728 cfg: &JiraConfig,
729 issue_key: &str,
730 body_wiki: &str,
731) -> Result<PostedComment> {
732 post_issue_comment_with_format(cfg, issue_key, body_wiki, CommentBodyFormat::Auto)
733}
734
735pub fn post_issue_comment_with_format(
737 cfg: &JiraConfig,
738 issue_key: &str,
739 body_wiki: &str,
740 format: CommentBodyFormat,
741) -> Result<PostedComment> {
742 if issue_key.trim().is_empty() {
743 bail!("issue key is empty");
744 }
745 if body_wiki.trim().is_empty() {
746 bail!("comment body is empty");
747 }
748 let resolved = format.resolve(&cfg.base_url);
749 let client = http_client()?;
750 let req = build_comment_post_request(&client, cfg, issue_key, body_wiki, resolved)
751 .with_context(|| format!("build comment POST for {issue_key}"))?;
752 let resp = client
753 .execute(req)
754 .with_context(|| format!("POST comment on {issue_key}"))?;
755 if !resp.status().is_success() {
756 let status = resp.status();
757 let detail = resp.text().unwrap_or_default();
758 bail!(
759 "posting comment on {issue_key} failed: HTTP {status} (format={resolved:?}){}",
760 if detail.is_empty() {
761 String::new()
762 } else {
763 format!(" — {detail}")
764 }
765 );
766 }
767 let text = resp.text().context("read comment create body")?;
768 let (comment_id, _) = parse_comment_create_response(&text)?;
769 Ok(PostedComment {
770 issue_key: issue_key.to_string(),
771 comment_id,
772 body_wiki: body_wiki.to_string(),
773 })
774}
775
776pub fn post_comment_from_markdown(
781 cfg: &JiraConfig,
782 issue_key: &str,
783 markdown_path: &Path,
784 dry_run: bool,
785) -> Result<PostedComment> {
786 post_comment_from_markdown_with_format(
787 cfg,
788 issue_key,
789 markdown_path,
790 dry_run,
791 CommentBodyFormat::Auto,
792 )
793}
794
795pub fn post_comment_from_markdown_with_format(
797 cfg: &JiraConfig,
798 issue_key: &str,
799 markdown_path: &Path,
800 dry_run: bool,
801 format: CommentBodyFormat,
802) -> Result<PostedComment> {
803 let wiki = crate::convert::convert_path_to_jira(markdown_path)
804 .with_context(|| format!("convert {} to jira markup", markdown_path.display()))?;
805 if dry_run {
806 return Ok(PostedComment {
807 issue_key: issue_key.to_string(),
808 comment_id: "(dry-run)".into(),
809 body_wiki: wiki,
810 });
811 }
812 post_issue_comment_with_format(cfg, issue_key, &wiki, format)
813}
814
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819
820 const SEARCH_FIXTURE: &str = r#"{
821 "expand": "names,schema",
822 "startAt": 0,
823 "maxResults": 50,
824 "total": 1,
825 "issues": [
826 {
827 "id": "10000",
828 "key": "HSP-1",
829 "self": "https://example.atlassian.net/rest/api/2/issue/10000",
830 "fields": { "summary": "Project space is not a backup" }
831 }
832 ]
833 }"#;
834
835 const COMMENTS_FIXTURE: &str = r#"{
836 "startAt": 0,
837 "maxResults": 50,
838 "total": 1,
839 "comments": [
840 {
841 "id": "10000",
842 "author": { "name": "fred", "displayName": "Fred F. User" },
843 "body": "h1. Heads up\n\nUse *self-service* for backups.",
844 "created": "2026-07-06T18:30:00.000+0000",
845 "updated": "2026-07-06T18:30:00.000+0000"
846 }
847 ]
848 }"#;
849
850 #[test]
851 fn parses_search_response_fixture() {
852 let (issues, start_at, total) = parse_search_response(SEARCH_FIXTURE).unwrap();
853 assert_eq!(start_at, 0);
854 assert_eq!(total, 1);
855 assert_eq!(issues.len(), 1);
856 assert_eq!(issues[0].key, "HSP-1");
857 assert_eq!(issues[0].summary, "Project space is not a backup");
858 }
859
860 #[test]
861 fn parses_comments_response_fixture() {
862 let comments = parse_comments_response(COMMENTS_FIXTURE).unwrap();
863 assert_eq!(comments.len(), 1);
864 assert_eq!(comments[0].author, "Fred F. User");
865 assert_eq!(comments[0].created, "2026-07-06T18:30:00.000+0000");
866 assert!(comments[0].body_wiki.contains("self-service"));
867 }
868
869 #[test]
870 fn slugify_collapses_punctuation_and_case() {
871 assert_eq!(
872 slugify("Project space is not a backup!"),
873 "project-space-is-not-a-backup"
874 );
875 assert_eq!(slugify(" --- "), "topic");
876 assert_eq!(slugify("SBU / GPU-hours (2026)"), "sbu-gpu-hours-2026");
877 }
878
879 #[test]
880 fn search_request_targets_v2_search_with_auth() {
881 let client = reqwest::blocking::Client::new();
882 let cfg = JiraConfig::new(
883 "https://example.atlassian.net/",
884 JiraAuth::Basic {
885 user: "advisor@example.org".into(),
886 token: "tok".into(),
887 },
888 );
889 let url = "https://example.atlassian.net/rest/api/2/search";
890 let req = build_search_request_at(&client, &cfg, url, "project = HSP", 0, 50).unwrap();
891 assert_eq!(req.url().path(), "/rest/api/2/search");
892 let query = req.url().query().unwrap_or_default();
893 assert!(query.contains("jql=project"), "{query}");
894 assert!(query.contains("maxResults=50"), "{query}");
895 assert!(req.headers().contains_key(reqwest::header::AUTHORIZATION));
896 let candidates = search_url_candidates("https://acme.atlassian.net");
897 assert!(candidates.iter().any(|u| u.ends_with("/rest/api/2/search")));
898 assert!(candidates.iter().any(|u| u.contains("/rest/api/3/search")));
899 }
900
901 #[test]
902 fn comments_request_targets_issue_path_with_bearer_header() {
903 let client = reqwest::blocking::Client::new();
904 let cfg = JiraConfig::new(
905 "https://jira.example.org",
906 JiraAuth::Header("Bearer some-pat".into()),
907 );
908 let req = build_comments_request_at(&client, &cfg, "HSP-1", "2").unwrap();
909 assert_eq!(req.url().path(), "/rest/api/2/issue/HSP-1/comment");
910 assert_eq!(
911 req.headers()
912 .get(reqwest::header::AUTHORIZATION)
913 .unwrap(),
914 "Bearer some-pat"
915 );
916 }
917
918 #[test]
919 fn draft_markdown_has_required_front_matter_and_sections() {
920 let text = draft_markdown(
921 "snell-example-hsp-1",
922 "HSP-1",
923 "Example topic",
924 &[(
925 "Fred F. User".into(),
926 "2026-07-06T18:30:00.000+0000".into(),
927 "Use **self-service** for backups.".into(),
928 )],
929 );
930 assert!(text.contains("id: snell-example-hsp-1"));
931 assert!(text.contains("prefix: snell"));
932 assert!(text.contains("sop: none"));
933 assert!(text.contains("imported from HSP-1"));
934 assert!(text.contains("## Comment by Fred F. User"));
935 assert!(text.contains("self-service"));
936 }
937
938 #[test]
939 fn from_env_requires_base_url() {
940 std::env::remove_var("JIRA_BASE_URL");
942 let err = JiraConfig::from_env().unwrap_err().to_string();
943 assert!(err.contains("JIRA_BASE_URL"));
944 }
945
946 const MYSELF_FIXTURE: &str = r#"{
947 "self": "https://example.atlassian.net/rest/api/2/user?username=advisor",
948 "name": "advisor",
949 "emailAddress": "advisor@example.org",
950 "displayName": "Advisor User",
951 "active": true,
952 "accountId": "abc-123"
953 }"#;
954
955 const SERVER_INFO_FIXTURE: &str = r#"{
956 "baseUrl": "https://example.atlassian.net",
957 "version": "9.12.15",
958 "versionNumbers": [9, 12, 15],
959 "deploymentType": "Server",
960 "serverTitle": "canonic-smoke"
961 }"#;
962
963 const COMMENT_CREATE_FIXTURE: &str = r#"{
964 "id": "30001",
965 "author": { "name": "advisor", "displayName": "Advisor User" },
966 "body": "h1. Smoke\n\nUse *self-service*.",
967 "created": "2026-07-08T12:00:00.000+0000"
968 }"#;
969
970 #[test]
971 fn parses_myself_for_probe() {
972 let (display, account) = parse_myself_response(MYSELF_FIXTURE).unwrap();
973 assert_eq!(display, "Advisor User");
974 assert_eq!(account, "abc-123");
975 }
976
977 #[test]
978 fn parses_server_info_for_probe() {
979 let (title, ver) = parse_server_info_response(SERVER_INFO_FIXTURE).unwrap();
980 assert_eq!(title.as_deref(), Some("canonic-smoke"));
981 assert_eq!(ver.as_deref(), Some("9.12.15"));
982 }
983
984 #[test]
985 fn format_probe_mentions_free_rest() {
986 let text = format_probe(&JiraProbe {
987 base_url: "https://example.atlassian.net".into(),
988 display_name: "Advisor User".into(),
989 account: "abc-123".into(),
990 server_title: Some("canonic-smoke".into()),
991 server_version: Some("9.12.15".into()),
992 cloud_host: true,
993 comment_format: CommentBodyFormat::Adf,
994 });
995 assert!(text.contains("free REST"));
996 assert!(text.contains("Advisor User"));
997 assert!(text.contains("Marketplace") || text.contains("no Marketplace"));
998 assert!(text.contains("https://example.atlassian.net"));
999 assert!(text.contains("Cloud") || text.contains("ADF") || text.contains("api/3"));
1000 }
1001
1002 #[test]
1003 fn myself_request_targets_v2_myself() {
1004 let client = reqwest::blocking::Client::new();
1005 let cfg = JiraConfig::new(
1006 "https://example.atlassian.net/",
1007 JiraAuth::Basic {
1008 user: "a@b.c".into(),
1009 token: "t".into(),
1010 },
1011 );
1012 let req = build_myself_request(&client, &cfg).unwrap();
1013 assert_eq!(req.url().path(), "/rest/api/2/myself");
1014 assert!(req.headers().contains_key(reqwest::header::AUTHORIZATION));
1015 }
1016
1017 #[test]
1018 fn comment_post_request_is_post_with_json_body() {
1019 let client = reqwest::blocking::Client::new();
1020 let cfg = JiraConfig::new(
1021 "https://jira.example.org",
1022 JiraAuth::Header("Bearer pat".into()),
1023 );
1024 let wiki = "h1. Smoke\n\nBody *bold*.";
1025 let req = build_comment_post_request(&client, &cfg, "HSP-101", wiki, CommentBodyFormat::Wiki).unwrap();
1026 assert_eq!(req.method(), reqwest::Method::POST);
1027 assert_eq!(req.url().path(), "/rest/api/2/issue/HSP-101/comment");
1028 let body = String::from_utf8_lossy(req.body().unwrap().as_bytes().unwrap());
1029 assert!(body.contains("body"), "{body}");
1030 assert!(body.contains("Smoke") || body.contains("h1"), "{body}");
1031 assert_eq!(
1032 req.headers().get(reqwest::header::AUTHORIZATION).unwrap(),
1033 "Bearer pat"
1034 );
1035 }
1036
1037 #[test]
1038 fn parses_comment_create_response() {
1039 let (id, body) = parse_comment_create_response(COMMENT_CREATE_FIXTURE).unwrap();
1040 assert_eq!(id, "30001");
1041 let body = body.unwrap_or_default();
1042 assert!(body.contains("self-service") || body.contains("h1"), "{body}");
1043 }
1044
1045 #[test]
1046 fn post_comment_from_markdown_dry_run_uses_pandoc_convert() {
1047 if !crate::convert::tool_available() {
1048 return;
1049 }
1050 let base = std::env::var_os("CARGO_TARGET_TMPDIR")
1051 .map(PathBuf::from)
1052 .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp"));
1053 let _ = std::fs::create_dir_all(&base);
1054 let md = base.join("snell-smoke-jira-comment.md");
1055 std::fs::write(
1056 &md,
1057 "---\nid: snell-smoke\ntitle: Smoke\nprefix: snell\nsop: none\n---\n\n# Smoke\n\nUse *self-service* for backups.\n\nRegards,\nSupport Team\n",
1058 )
1059 .expect("write smoke markdown under target/");
1060 let cfg = JiraConfig::new(
1061 "http://127.0.0.1:9",
1062 JiraAuth::Basic {
1063 user: "x".into(),
1064 token: "y".into(),
1065 },
1066 );
1067 let posted = post_comment_from_markdown(&cfg, "HSP-101", &md, true).expect("dry-run");
1068 assert_eq!(posted.comment_id, "(dry-run)");
1069 assert_eq!(posted.issue_key, "HSP-101");
1070 let via_convert = crate::convert::convert_path_to_jira(&md).expect("convert");
1071 assert_eq!(posted.body_wiki, via_convert);
1072 assert!(
1073 posted.body_wiki.contains("self-service")
1074 || posted.body_wiki.contains("Smoke")
1075 || posted.body_wiki.contains("h1"),
1076 "unexpected wiki: {:?}",
1077 posted.body_wiki
1078 );
1079 let _ = std::fs::remove_file(&md);
1080 }
1081
1082 #[test]
1083 fn cloud_host_detection() {
1084 assert!(is_cloud_host("https://acme.atlassian.net"));
1085 assert!(!is_cloud_host("https://jira.example.org"));
1086 assert_eq!(
1087 CommentBodyFormat::Auto.resolve("https://x.atlassian.net"),
1088 CommentBodyFormat::Adf
1089 );
1090 assert_eq!(
1091 CommentBodyFormat::Auto.resolve("http://localhost:8080"),
1092 CommentBodyFormat::Wiki
1093 );
1094 }
1095
1096 #[test]
1097 fn plain_text_to_adf_is_minimal_doc() {
1098 let adf = plain_text_to_adf("line one\n\nline two");
1099 assert_eq!(adf["type"], "doc");
1100 assert_eq!(adf["version"], 1);
1101 assert!(adf["content"].as_array().unwrap().len() >= 1);
1102 let s = adf.to_string();
1103 assert!(s.contains("line one"));
1104 assert!(s.contains("line two"));
1105 }
1106
1107 #[test]
1108 fn comment_body_to_text_reads_wiki_string_and_adf() {
1109 let wiki = serde_json::json!("h1. Hello");
1110 assert_eq!(comment_body_to_text(&wiki), "h1. Hello");
1111 let adf = serde_json::json!({
1112 "type": "doc",
1113 "version": 1,
1114 "content": [{
1115 "type": "paragraph",
1116 "content": [{"type": "text", "text": "Hello ADF"}]
1117 }]
1118 });
1119 assert!(comment_body_to_text(&adf).contains("Hello ADF"));
1120 }
1121
1122 #[test]
1123 fn cloud_comment_post_uses_api3_and_adf() {
1124 let client = reqwest::blocking::Client::new();
1125 let cfg = JiraConfig::new(
1126 "https://acme.atlassian.net",
1127 JiraAuth::Basic {
1128 user: "a@b.c".into(),
1129 token: "t".into(),
1130 },
1131 );
1132 let req = build_comment_post_request(
1133 &client,
1134 &cfg,
1135 "HSP-1",
1136 "hello *world*",
1137 CommentBodyFormat::Auto,
1138 )
1139 .unwrap();
1140 assert_eq!(req.url().path(), "/rest/api/3/issue/HSP-1/comment");
1141 let body = String::from_utf8_lossy(req.body().unwrap().as_bytes().unwrap());
1142 assert!(body.contains("\"type\":\"doc\"") || body.contains("\"doc\""), "{body}");
1143 assert!(body.contains("hello") || body.contains("world"), "{body}");
1144 }
1145}