Skip to main content

canonic/
jira_import.rs

1//! Free-tier Jira **platform** REST only (no Marketplace apps).
2//!
3//! Endpoint map (official Cloud + Server/DC docs):
4//!
5//! | Op | Endpoint | Notes |
6//! |----|----------|-------|
7//! | Probe | `GET /rest/api/2/myself` (+ `serverInfo`) | Cloud Free email+API token or Server PAT |
8//! | Search | `GET /rest/api/2/search?jql=&fields=summary` | Free JQL; Server also allows POST search |
9//! | Comments GET | `GET /rest/api/2/issue/{key}/comment` | Wiki string **or** Cloud ADF body |
10//! | Comment POST | Server: `POST /rest/api/2/.../comment` wiki · Cloud: `POST /rest/api/3/.../comment` ADF | No paid formatters |
11//!
12//! Import writes drafts under `corpus/imports/` only. Write is explicit one-shot
13//! comment post (review-before-migrate), not bulk library sync.
14
15use 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
29/// Where imported drafts land by default (never auto-indexed or quality-checked).
30pub fn default_import_dir() -> PathBuf {
31    PathBuf::from("corpus/imports")
32}
33
34/// How to authenticate against the Jira REST API.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum JiraAuth {
37    /// Jira Cloud convention: an account email plus an API token.
38    Basic { user: String, token: String },
39    /// A raw `Authorization` header value, e.g. `Bearer <personal-access-token>`
40    /// for Jira Server/Data Center.
41    Header(String),
42}
43
44/// Jira instance connection details.
45#[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    /// Read `JIRA_BASE_URL` plus either `JIRA_AUTH_HEADER` (a raw `Authorization`
60    /// value) or `JIRA_EMAIL` + `JIRA_API_TOKEN` (Basic auth).
61    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/// One issue found by a JQL search: key plus its summary.
104#[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    /// Cloud free REST may return ADF objects; Server/DC wiki is a string.
133    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/// One comment on a Jira issue, with its body still in Jira wiki markup.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct IssueComment {
146    pub author: String,
147    pub created: String,
148    pub body_wiki: String,
149}
150
151/// Flatten free-platform comment bodies: wiki strings stay as-is; Cloud ADF
152/// (Atlassian Document Format) is reduced to plain text without Marketplace apps.
153pub 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                // paragraph/heading breaks
185                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
199/// Build minimal free ADF document from plain/wiki text (Cloud comment write path).
200///
201/// Official Cloud v3 comments expect Atlassian Document Format bodies
202/// (developer.atlassian.com Cloud platform REST). This does not use paid apps.
203pub 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/// How to encode free write comment bodies for platform REST.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum CommentBodyFormat {
237    /// Server/Data Center wiki string: `{"body":"h1. ..."}` on `/rest/api/2/...`.
238    Wiki,
239    /// Cloud Free-compatible ADF on `/rest/api/3/...` (no Marketplace formatter).
240    Adf,
241    /// `*.atlassian.net` → Adf, otherwise Wiki.
242    #[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
261/// True when the base URL looks like Jira Cloud Free/Standard host.
262pub 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
281/// Free search URL candidates (Server v2 first; Cloud Free may prefer v3).
282fn 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        // Still try v3 paths for forward-compat fixtures / hybrid hosts.
290        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
328/// Execute one free-tier search page, trying platform URL candidates on failure.
329fn 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            // try next candidate on 404/410/400/405 (deprecated/moved Cloud paths)
358            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
370/// Search for issues matching `jql`, paginating until Jira reports no more.
371///
372/// Tries free platform search paths in order (api/2 then Cloud api/3 variants).
373pub 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
394/// Fetch all comments for one issue (api/2, then api/3 on Cloud-style hosts).
395pub fn fetch_comments(cfg: &JiraConfig, issue_key: &str) -> Result<Vec<IssueComment>> {
396    let client = http_client()?;
397    // Try classic v2 first (Server/DC + Cloud Free still expose it), then v3.
398    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
422/// Turn a title into a slug: lowercase ASCII alphanumerics, everything else
423/// collapsed to a single `-`, with no leading, trailing, or repeated dash.
424pub fn slugify(title: &str) -> String {
425    let mut slug = String::new();
426    let mut last_dash = true; // suppress a leading dash
427    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
446/// Render one issue's comments as a review draft: front matter plus one
447/// section per comment. `id` must already carry the `snell-` prefix.
448pub 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
466/// Import issues matching `jql` into `out_dir` as review drafts (never
467/// `corpus/responses/`). Returns the paths written, or, when `dry_run` is
468/// set, the paths that would be written without touching the filesystem or
469/// fetching comments.
470pub 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/// Result of a free-tier connectivity probe (`/myself` + optional serverInfo).
510#[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    /// Cloud Free-style host (`*.atlassian.net`) vs Server/DC-style.
518    pub cloud_host: bool,
519    /// Resolved free comment write format for this host.
520    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
584/// Probe free Jira REST: authenticated identity + optional server banner.
585///
586/// Uses only platform endpoints (`/rest/api/2/myself`, `/serverInfo`) — no
587/// Marketplace apps. Exit path for CLI: non-zero when HTTP fails or auth rejects.
588pub 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
629/// Format a probe result for CLI stdout.
630pub 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/// One comment created via free REST POST.
662#[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
686/// Free platform comment endpoints (official Cloud/Server REST — no Marketplace).
687///
688/// | Host | Format | Endpoint |
689/// |------|--------|----------|
690/// | Server/DC | wiki string | `POST /rest/api/2/issue/{key}/comment` `{"body":"wiki"}` |
691/// | Cloud Free | ADF | `POST /rest/api/3/issue/{key}/comment` ADF `body` object |
692///
693/// See developer.atlassian.com Cloud platform REST (issue comments) and Server
694/// REST API examples. Auth: Cloud email+API token Basic, or Server PAT header.
695fn 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
723/// POST a comment via free platform REST (wiki on Server/DC, ADF on Cloud Free).
724///
725/// `body_wiki` is pandoc jira markup (or plain text). On Cloud Free, it is wrapped
726/// in minimal ADF. No Marketplace apps.
727pub 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
735/// Like [`post_issue_comment`] with an explicit body format.
736pub 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
776/// Convert a markdown corpus file with pandoc jira writer, then POST as a comment.
777///
778/// When `dry_run` is true, returns the wiki body that would be posted without
779/// calling Jira (still requires pandoc for a real conversion).
780pub 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
795/// Like [`post_comment_from_markdown`] with an explicit free-tier body format.
796pub 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        // Isolated by variable name; does not touch real Jira config.
941        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}