Skip to main content

cratefield_adapter_jira/
lib.rs

1//! `cratefield-adapter-jira`: the [`Tracker`] port over the Jira Cloud
2//! REST API v3 (issue #559). Uses the runtime's [`HttpClient`] and
3//! [`Clock`] ports — no vendor SDK — so the same adapter runs on Workers
4//! and natively (ADR 0002: the port lives in core, the vendor client here).
5//!
6//! **At-least-once means search-before-create**, the same rule the sibling
7//! `cratefield-adapter-github-issues` follows: the outbox may call
8//! [`Tracker::file`] twice for one [`TicketDraft`], so the adapter stamps
9//! every issue with a derived label and looks for its own stamp by JQL
10//! before creating. A lookup that *fails* fails the whole call — creating
11//! after an unreliable dedupe check is exactly the double-file the check
12//! exists to prevent.
13
14#![doc = include_str!("../README.md")]
15#![forbid(unsafe_code)]
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use base64::Engine;
22use bytes::Bytes;
23use cratefield_core::{
24    Clock, Credential, Destination, Filed, HttpClient, HttpError, InboundStatusError,
25    ProviderScheme, SignatureEncoding, StatusUpdate, StatusWebhook, TicketComment, TicketDraft,
26    TicketState, TicketStatus, Tracker, TrackerError, WebhookVerifier, retry_after,
27};
28use futures_util::lock::Mutex as AsyncMutex;
29use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
30use http::{Request, StatusCode};
31use serde_json::Value;
32use sha2::{Digest, Sha256};
33
34/// The issue type every ticket is filed as, unless
35/// [`JiraCloud::with_issue_type`] names another. The name must already
36/// exist in the destination project — Jira refuses the create with a `400`
37/// otherwise, which surfaces as [`TrackerError::Rejected`].
38const DEFAULT_ISSUE_TYPE: &str = "Bug";
39/// Jira's documented cap on the `summary` field; a longer title is
40/// truncated on a character boundary rather than failing the create.
41const MAX_SUMMARY_CHARS: usize = 255;
42/// The comment property carrying the caller's idempotency key, so a
43/// retried note stays greppable in Jira even though comments have no
44/// search-before-write.
45const IDEM_PROPERTY: &str = "cratefield-idem";
46/// The label prefix tying an issue to an outbox idempotency key — the
47/// Jira counterpart of the GitHub adapter's invisible HTML comment. It is
48/// the crate's own prefix, so a venture's own labels can never collide
49/// with it by accident.
50const IDEM_LABEL_PREFIX: &str = "cratefield-idem-";
51/// Jira Cloud's current search endpoint (the one `search/jql` moved to),
52/// where the dedupe lookup runs.
53const SEARCH_PATH: &str = "/rest/api/3/search/jql";
54
55/// `Tracker` over the Jira Cloud REST API v3.
56///
57/// No `Debug` derive: kept explicit so a field added later cannot print
58/// `Debug` would print it wherever a log line or a panic message met the
59/// adapter.
60pub struct JiraCloud {
61    http: Arc<dyn HttpClient>,
62    /// Needed only to read the HTTP-date form of `Retry-After` (issue
63    /// #278), the same reason the GitHub Issues adapter holds one. A
64    /// constructor argument rather than a builder default so a deployment
65    /// that forgets it fails to compile instead of silently retrying a
66    /// date-form 429 immediately.
67    clock: Arc<dyn Clock>,
68    issue_type: String,
69    /// `None` — the default — builds each URL from the destination's own
70    /// site (`https://{site}/rest/api/3/…`), which is what Jira Cloud is.
71    /// A value overrides the scheme and host for tests pointed at a fake
72    /// ([`JiraCloud::with_base`]).
73    base: Option<String>,
74    /// Held across the whole search-then-create of one [`Tracker::file`],
75    /// so two in-process callers racing on one idempotency key cannot
76    /// both observe "no such issue" and both create one. Jira labels are
77    /// not a uniqueness constraint, so nothing on the provider side
78    /// closes that window — this gate is the same single-flight
79    /// `GithubApp` holds across its token exchange.
80    ///
81    /// It serialises *all* filings on one adapter, not just same-key
82    /// ones: a per-key table would leak an entry per key ever filed and
83    /// buy nothing a retry does not already give — the create is one
84    /// POST either way.
85    file_gate: AsyncMutex<()>,
86}
87
88impl std::fmt::Debug for JiraCloud {
89    /// Holds no credential to leak: the `email:api_token` pair arrives per
90    /// call (#453).
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("JiraCloud")
93            .field("issue_type", &self.issue_type)
94            .field("base", &self.base)
95            // The http and clock ports are struct fields too; nothing about
96            // either belongs in a log line.
97            .finish_non_exhaustive()
98    }
99}
100
101impl JiraCloud {
102    /// An adapter pointed at Jira Cloud. It holds no credential: which
103    /// site, addressed under whose `email:api_token`, is tenant data and
104    /// arrives with each call (#453).
105    pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
106        Self {
107            http,
108            clock,
109            issue_type: DEFAULT_ISSUE_TYPE.to_owned(),
110            base: None,
111            file_gate: AsyncMutex::new(()),
112        }
113    }
114
115    /// Files every ticket as issue type `name` instead of `"Bug"`.
116    #[must_use]
117    pub fn with_issue_type(mut self, name: impl Into<String>) -> Self {
118        self.issue_type = name.into();
119        self
120    }
121
122    /// Overrides the scheme and host every request is built from — a fake
123    /// in tests — instead of `https://` plus the destination's own site.
124    #[must_use]
125    pub fn with_base(mut self, base: impl Into<String>) -> Self {
126        self.base = Some(base.into());
127        self
128    }
129
130    /// The absolute URL for `path` under the destination's `site`.
131    fn endpoint(&self, site: &str, path: &str) -> String {
132        match &self.base {
133            Some(base) => format!("{base}{path}"),
134            None => format!("https://{site}{path}"),
135        }
136    }
137
138    /// The headers every Jira request carries: HTTP Basic over the
139    /// `email:api_token` credential, and the JSON accept Jira v3 answers
140    /// with. Takes the credential per call: whose site is addressed under
141    /// whose token is tenant data (#453), so the adapter holds none — and
142    /// nothing formats the pair but the request itself, so no log line
143    /// can meet it.
144    fn request(cred: &Credential, method: http::Method, uri: String) -> http::request::Builder {
145        Request::builder()
146            .method(method)
147            .uri(uri)
148            .header(AUTHORIZATION, basic_auth(cred))
149            .header(ACCEPT, "application/json")
150    }
151
152    /// The dedupe lookup: one JQL search for this project's issue carrying
153    /// the idempotency label. Jira's label search matches whole labels, so
154    /// unlike GitHub's token-matching text search no body verify is needed
155    /// on the hit. `Err` is fail-closed: it propagates out of `file`
156    /// instead of falling through to the create, because a ticket created
157    /// after an *unreliable* dedupe check is exactly the duplicate the
158    /// check exists to prevent.
159    async fn find_existing(
160        &self,
161        cred: &Credential,
162        site: &str,
163        project: &str,
164        label: &str,
165    ) -> Result<Option<Filed>, TrackerError> {
166        let jql = format!("project = \"{project}\" AND labels = \"{label}\"");
167        let uri = format!(
168            "{}?jql={}&fields=status&maxResults=1",
169            self.endpoint(site, SEARCH_PATH),
170            percent_encode(&jql)
171        );
172        let request = Self::request(cred, http::Method::GET, uri)
173            .body(Bytes::new())
174            .map_err(|_| TrackerError::Transient { retry_after: None })?;
175        let text = self.send_checked(request).await?;
176        let parsed: SearchResponse = serde_json::from_str(&text)
177            .map_err(|_| TrackerError::Transient { retry_after: None })?;
178        Ok(parsed.issues.into_iter().find_map(|issue| {
179            issue.key.map(|key| Filed {
180                external_id: key.clone(),
181                url: browse_url(site, &key),
182            })
183        }))
184    }
185
186    /// Sends one request and returns the body of a `2xx` answer. Anything
187    /// else — a transport failure or a non-2xx status — comes back as a
188    /// [`TrackerError`], already mapped, so a lookup caller cannot fall
189    /// through a failed check into a create.
190    async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
191        let response = self.http.send(request).await.map_err(|err: HttpError| {
192            match err {
193                // The port's SSRF vetting refused the destination URL
194                // (a scheme, userinfo, or a loopback / private / link-local /
195                // metadata destination, re-vetted per redirect hop). That is
196                // a config error, not weather: retrying will not fix it.
197                HttpError::BlockedDestination(detail) => {
198                    TrackerError::Rejected(format!("jira destination refused: {detail}"))
199                }
200                // Any other transport failure is weather — retry. The
201                // provider's text cannot ride `Transient`, which carries
202                // only the delay, so it goes to the log the way the
203                // sibling adapters' does: an operator still gets to read it.
204                other => {
205                    tracing::warn!(
206                        provider = "jira",
207                        outcome = "failed",
208                        error = %other,
209                        "tracker outcome"
210                    );
211                    TrackerError::Transient { retry_after: None }
212                }
213            }
214        })?;
215        let status = response.status();
216        let text = String::from_utf8_lossy(response.body()).to_string();
217        if !status.is_success() {
218            // One parser for both `Retry-After` forms (issue #214/#278); the
219            // date form needs the clock this adapter is constructed with.
220            let delay = retry_after(response.headers(), self.clock.as_ref());
221            let error = Self::map_status(status, &text, delay);
222            tracing::warn!(
223                provider = "jira",
224                code = status.as_u16(),
225                outcome = "failed",
226                detail = %text,
227                error = %error,
228                "tracker outcome"
229            );
230            return Err(error);
231        }
232        Ok(text)
233    }
234
235    /// Maps a Jira status onto the port's error taxonomy — the same table
236    /// the GitHub Issues adapter applies: `401`/`403` say the
237    /// `email:api_token` pair is wrong or powerless; the rest of the
238    /// `4xx` family (Jira answers a bad project key, a missing issue type,
239    /// an unpermitted field with a `400` carrying `errorMessages`/`errors`)
240    /// is a fact about our request, which a retry will not fix; `429` and
241    /// the `5xx` family are the provider having a moment. A stray `3xx` an
242    /// unfollowing proxy produced is weather, not a verdict on the draft.
243    fn map_status(
244        status: StatusCode,
245        body: &str,
246        retry_after: Option<std::time::Duration>,
247    ) -> TrackerError {
248        match status {
249            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
250            // `429` and the `5xx` family are the provider having a moment:
251            // retry, and not before `retry_after` where it named one.
252            // (`retry_after` reads both header forms and answers `None` when
253            // no header came, so a bare 500 stays unscheduled.) This arm
254            // must precede the `4xx` one — 429 *is* a client error.
255            status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
256                TrackerError::Transient { retry_after }
257            }
258            status if status.is_client_error() => {
259                TrackerError::Rejected(format!("jira {status}: {}", error_detail(body)))
260            }
261            _ => TrackerError::Transient { retry_after },
262        }
263    }
264}
265
266#[async_trait]
267impl Tracker for JiraCloud {
268    async fn file(
269        &self,
270        dest: &Destination,
271        cred: &Credential,
272        draft: &TicketDraft,
273    ) -> Result<Filed, TrackerError> {
274        let (site, project) = site_and_project(dest)?;
275        validate_site(site)?;
276        validate_project(project)?;
277        let label = idem_label(&draft.idempotency_key);
278
279        // Held across the lookup AND the create: the gate is what makes
280        // search-before-create safe rather than merely hopeful. A second
281        // caller arriving mid-flight waits here, and by the time its JQL
282        // search runs the first one's labelled issue exists. Taken after
283        // the destination validation, so a malformed site is refused
284        // without queueing behind a stranger's create.
285        let _single_flight = self.file_gate.lock().await;
286
287        // Search before creating. The outbox is at-least-once, so this WILL
288        // be called twice for one draft; and the `?` here is the fail-closed
289        // rule — a lookup that itself fails ends the call.
290        if let Some(existing) = self.find_existing(cred, site, project, &label).await? {
291            tracing::info!(
292                provider = "jira",
293                outcome = "deduplicated",
294                idempotency = %draft.idempotency_key,
295                issue = %existing.external_id,
296                "tracker outcome"
297            );
298            return Ok(existing);
299        }
300
301        let payload = serde_json::json!({
302            "fields": {
303                "project": { "key": project },
304                "summary": truncate_summary(&draft.title),
305                "issuetype": { "name": self.issue_type },
306                "labels": labels(draft, &label),
307                "description": adf_document(&draft.body_markdown),
308            }
309        });
310        let json = serde_json::to_vec(&payload)
311            .map_err(|_| TrackerError::Transient { retry_after: None })?;
312        let request = Self::request(
313            cred,
314            http::Method::POST,
315            self.endpoint(site, "/rest/api/3/issue"),
316        )
317        .header(CONTENT_TYPE, "application/json")
318        .body(Bytes::from(json))
319        .map_err(|_| TrackerError::Transient { retry_after: None })?;
320        let text = self.send_checked(request).await?;
321        let created: CreatedIssue = serde_json::from_str(&text).map_err(|_| {
322            // A 2xx we cannot read is not a create we can trust: report it
323            // transient, and let the next attempt's dedupe check settle it.
324            TrackerError::Transient { retry_after: None }
325        })?;
326        let Some(key) = created.key else {
327            return Err(TrackerError::Transient { retry_after: None });
328        };
329        tracing::info!(
330            provider = "jira",
331            outcome = "created",
332            idempotency = %draft.idempotency_key,
333            issue = %key,
334            "tracker outcome"
335        );
336        Ok(Filed {
337            external_id: key.clone(),
338            url: browse_url(site, &key),
339        })
340    }
341
342    /// Jira has a ticket to read back, so this is a real read: GET the
343    /// issue's status and map its `statusCategory` — Jira's own rollup,
344    /// the field that survives every custom workflow a tenant renames —
345    /// onto the port's five.
346    async fn status(
347        &self,
348        dest: &Destination,
349        cred: &Credential,
350        external_id: &str,
351    ) -> Result<TicketStatus, TrackerError> {
352        let (site, _) = site_and_project(dest)?;
353        validate_site(site)?;
354        validate_issue_key(external_id)?;
355        let uri = format!(
356            "{}?fields=status",
357            self.endpoint(site, &format!("/rest/api/3/issue/{external_id}"))
358        );
359        let request = Self::request(cred, http::Method::GET, uri)
360            .body(Bytes::new())
361            .map_err(|_| TrackerError::Transient { retry_after: None })?;
362        let text = self.send_checked(request).await?;
363        let parsed: Value = serde_json::from_str(&text)
364            .map_err(|_| TrackerError::Transient { retry_after: None })?;
365        // `Value` indexing answers `Null`, never panics, for every missing
366        // hop — a status Jira answered without is an unknown, not a crash.
367        let state = match parsed["fields"]["status"]["statusCategory"]["key"].as_str() {
368            Some("new") => TicketState::Open,
369            Some("indeterminate") => TicketState::InProgress,
370            Some("done") => TicketState::Resolved,
371            // The port names what it can; a category it cannot is said so
372            // rather than guessed into one of the four above.
373            _ => TicketState::Unknown,
374        };
375        Ok(TicketStatus {
376            external_id: external_id.to_owned(),
377            state,
378            url: Some(browse_url(site, external_id)),
379        })
380    }
381
382    /// Posts the note as a Jira comment. There is no search-before-write
383    /// here — comments are not worth a second request — so a redelivery
384    /// **may duplicate the note**; the caller's idempotency key rides a
385    /// comment property, which makes any duplicate findable and deletable.
386    async fn comment(
387        &self,
388        dest: &Destination,
389        cred: &Credential,
390        external_id: &str,
391        comment: &TicketComment,
392    ) -> Result<(), TrackerError> {
393        let (site, _) = site_and_project(dest)?;
394        validate_site(site)?;
395        validate_issue_key(external_id)?;
396        let body = match comment.link.as_deref() {
397            // The link becomes a paragraph with a link mark; only an
398            // http(s) link is worth one, decided before any request.
399            Some(link) if link.starts_with("https://") || link.starts_with("http://") => {
400                adf_with_link(&comment.body_markdown, link)
401            }
402            Some(_) => {
403                return Err(TrackerError::Rejected(
404                    "jira comment link must be an http(s) URL".to_owned(),
405                ));
406            }
407            None => adf_document(&comment.body_markdown),
408        };
409        let payload = serde_json::json!({
410            "body": body,
411            "properties": [{ "key": IDEM_PROPERTY, "value": comment.idempotency_key }],
412        });
413        let json = serde_json::to_vec(&payload)
414            .map_err(|_| TrackerError::Transient { retry_after: None })?;
415        let uri = self.endpoint(site, &format!("/rest/api/3/issue/{external_id}/comment"));
416        let request = Self::request(cred, http::Method::POST, uri)
417            .header(CONTENT_TYPE, "application/json")
418            .body(Bytes::from(json))
419            .map_err(|_| TrackerError::Transient { retry_after: None })?;
420        let _ = self.send_checked(request).await?;
421        tracing::info!(
422            provider = "jira",
423            outcome = "commented",
424            idempotency = %comment.idempotency_key,
425            issue = external_id,
426            "tracker outcome"
427        );
428        Ok(())
429    }
430}
431
432/// The `Authorization` value for Atlassian's API-token scheme: HTTP Basic
433/// over the caller's `email:api_token` pair, standard base64 exactly as
434/// RFC 7617 (and Atlassian's docs) spell it. The pair is the credential;
435/// it lives only here and only inside the request.
436fn basic_auth(cred: &Credential) -> String {
437    format!(
438        "Basic {}",
439        base64::engine::general_purpose::STANDARD.encode(cred.expose().as_bytes())
440    )
441}
442
443/// The labels a filed issue carries: the draft's own, the idempotency
444/// label the dedupe search greps for, and one naming the severity — the
445/// port's [`Severity`] rendered as the flat `severity/<name>` text a Jira
446/// label is. None contains a space, which Jira labels cannot carry.
447fn labels(draft: &TicketDraft, idem_label: &str) -> Vec<String> {
448    let mut all = Vec::with_capacity(draft.labels.len() + 2);
449    all.extend(draft.labels.iter().cloned());
450    all.push(idem_label.to_owned());
451    all.push(format!("severity/{}", draft.severity.name()));
452    all
453}
454
455/// The label that ties an issue to an outbox idempotency key:
456/// [`IDEM_LABEL_PREFIX`] plus the first 16 bytes of SHA-256 of the key,
457/// hex — hashed, because a label is flat text a search greps for and a
458/// caller's key may quote tenant data.
459fn idem_label(idempotency_key: &str) -> String {
460    let digest = Sha256::digest(idempotency_key.as_bytes());
461    format!("{IDEM_LABEL_PREFIX}{}", hex::encode(&digest[..16]))
462}
463
464/// Jira caps `summary` at [`MAX_SUMMARY_CHARS`]; a longer title is
465/// truncated on a character boundary — a split character is something
466/// serde's JSON writer would refuse to emit.
467fn truncate_summary(title: &str) -> &str {
468    match title.char_indices().nth(MAX_SUMMARY_CHARS) {
469        Some((at, _)) => &title[..at],
470        None => title,
471    }
472}
473
474/// The site and project a [`Destination::Jira`] names, or the rejection
475/// for any other destination — decided before any request, the way the
476/// GitHub adapter refuses a webhook.
477fn site_and_project(dest: &Destination) -> Result<(&str, &str), TrackerError> {
478    match dest {
479        Destination::Jira { site, project } => Ok((site.as_str(), project.as_str())),
480        other => Err(TrackerError::unsupported_destination(other)),
481    }
482}
483
484/// A site must be a bare hostname (`acme.atlassian.net`): scheme, path,
485/// port, userinfo and whitespace are all rejected before any request, so
486/// a malformed tenant value cannot move the Basic credential off the host
487/// it names. The allowlist is a hostname's own characters —
488/// alphanumerics, dots and hyphens — which is narrower than any URL
489/// parser's and cannot be talked past.
490fn validate_site(site: &str) -> Result<(), TrackerError> {
491    let valid = !site.is_empty()
492        && site.len() <= 253
493        && site
494            .chars()
495            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
496        && !site.starts_with(['-', '.'])
497        && !site.ends_with(['-', '.'])
498        && !site.contains("..");
499    if valid {
500        Ok(())
501    } else {
502        Err(TrackerError::Rejected(
503            "jira site must be a bare hostname like acme.atlassian.net".to_owned(),
504        ))
505    }
506}
507
508/// A project key is Jira's own shape: uppercase letters, digits and
509/// underscores, starting with a letter. Decided before any request so a
510/// malformed key cannot become a mangled URL.
511fn validate_project(project: &str) -> Result<(), TrackerError> {
512    let valid = !project.is_empty()
513        && project.len() <= 255
514        && project.starts_with(|c: char| c.is_ascii_uppercase())
515        && project
516            .chars()
517            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
518    if valid {
519        Ok(())
520    } else {
521        Err(TrackerError::Rejected(
522            "jira project key must be uppercase letters, digits or underscores, starting with a \
523             letter (e.g. PROJ)"
524                .to_owned(),
525        ))
526    }
527}
528
529/// An external id must look like a Jira key (`PROJ-123`) before it is put
530/// into a URL: project-key-shaped, one hyphen, then digits. A value that
531/// does not parse never rides a path this adapter builds.
532fn validate_issue_key(external_id: &str) -> Result<(), TrackerError> {
533    let shaped = match external_id.split_once('-') {
534        Some((key, number)) => {
535            !key.is_empty()
536                && !number.is_empty()
537                && number.bytes().all(|b| b.is_ascii_digit())
538                && key.starts_with(|c: char| c.is_ascii_uppercase())
539                && key
540                    .chars()
541                    .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
542        }
543        None => false,
544    };
545    if shaped {
546        Ok(())
547    } else {
548        Err(TrackerError::Rejected(
549            "jira external id must look like a Jira key (e.g. PROJ-123)".to_owned(),
550        ))
551    }
552}
553
554/// The human URL for an issue: `https://{site}/browse/{key}` — Jira Cloud
555/// renders every issue there.
556fn browse_url(site: &str, key: &str) -> String {
557    format!("https://{site}/browse/{key}")
558}
559
560/// The human URL rebuilt from an issue's `self` API URL
561/// (`https://{host}/rest/api/3/issue/10002` → `https://{host}/browse/KEY`),
562/// for a webhook payload that names no site of its own: `None` when the
563/// payload carries no sensible one.
564fn browse_url_from_self(api_self: Option<&str>, key: &str) -> Option<String> {
565    let (scheme, rest) = api_self?.split_once("://")?;
566    if scheme != "http" && scheme != "https" {
567        return None;
568    }
569    let host = rest.split('/').next().filter(|host| !host.is_empty())?;
570    Some(format!("{scheme}://{host}/browse/{key}"))
571}
572
573/// The body's Markdown as Jira's Atlassian Document Format: each
574/// blank-line-separated block becomes one paragraph of one plain text
575/// node — the Markdown syntax rides as literal text, which Jira renders
576/// verbatim — and the blank lines themselves are skipped. A draft whose
577/// body is only blanks still gets one empty paragraph: Jira v3 can reject
578/// a document whose `content` array is empty, and an empty paragraph is
579/// the shape Jira's own editor writes for a blank page.
580fn adf_document(markdown: &str) -> Value {
581    let paragraphs = markdown
582        .split("\n\n")
583        .map(str::trim)
584        .filter(|block| !block.is_empty())
585        .map(|block| {
586            serde_json::json!({
587                "type": "paragraph",
588                "content": [{ "type": "text", "text": block }],
589            })
590        })
591        .collect::<Vec<_>>();
592    let content = if paragraphs.is_empty() {
593        vec![serde_json::json!({ "type": "paragraph", "content": [] })]
594    } else {
595        paragraphs
596    };
597    serde_json::json!({ "type": "doc", "version": 1, "content": content })
598}
599
600/// The comment body's [`adf_document`] plus one paragraph carrying
601/// `href` as a text node with a link mark — the one way ADF renders a
602/// clickable URL.
603fn adf_with_link(markdown: &str, href: &str) -> Value {
604    let mut document = adf_document(markdown);
605    if let Some(paragraphs) = document["content"].as_array_mut() {
606        paragraphs.push(serde_json::json!({
607            "type": "paragraph",
608            "content": [{
609                "type": "text",
610                "text": href,
611                "marks": [{ "type": "link", "attrs": { "href": href } }],
612            }],
613        }));
614    }
615    document
616}
617
618/// The human text inside a Jira error body — the `errorMessages` array
619/// plus the field-by-field `errors` map — flattened to one line. It rides
620/// `TrackerError::Rejected`, whose `Display` scrubs it: a Jira error can
621/// quote tenant data. A body that is not JSON at all is quoted whole.
622fn error_detail(body: &str) -> String {
623    let Ok(parsed) = serde_json::from_str::<ErrorResponse>(body) else {
624        return body.to_owned();
625    };
626    let mut parts = parsed.error_messages;
627    for (field, message) in parsed.errors {
628        parts.push(format!("{field}: {message}"));
629    }
630    if parts.is_empty() {
631        body.to_owned()
632    } else {
633        parts.join("; ")
634    }
635}
636
637/// Percent-encodes a value for a query parameter. The allowlist is RFC 3986's
638/// unreserved set, so the quoted JQL's spaces, `"` and `=` all come out
639/// encoded instead of terminating the parameter early. (No `url` crate:
640/// one parameter is not worth a dependency.)
641fn percent_encode(value: &str) -> String {
642    use std::fmt::Write as _;
643    let mut encoded = String::with_capacity(value.len());
644    for byte in value.bytes() {
645        match byte {
646            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
647                encoded.push(byte as char);
648            }
649            other => {
650                // Writing to a String cannot fail.
651                let _ = write!(encoded, "%{other:02X}");
652            }
653        }
654    }
655    encoded
656}
657
658/// The slice of a create or search answer this adapter reads: the issue
659/// key (`PROJ-7`), what the REST API addresses a ticket by.
660#[derive(serde::Deserialize)]
661struct CreatedIssue {
662    #[serde(default)]
663    key: Option<String>,
664}
665
666#[derive(serde::Deserialize)]
667struct SearchResponse {
668    #[serde(default)]
669    issues: Vec<CreatedIssue>,
670}
671
672/// The two places Jira puts human text in an error body.
673#[derive(serde::Deserialize)]
674#[serde(rename_all = "camelCase")]
675struct ErrorResponse {
676    #[serde(default)]
677    error_messages: Vec<String>,
678    #[serde(default)]
679    errors: HashMap<String, String>,
680}
681
682/// Jira Cloud's inbound status webhook, over core's [`ProviderScheme`]:
683/// `X-Hub-Signature: sha256=<hex>`, HMAC-SHA256 over the raw body, read by
684/// [`cratefield_core::receive_status`] — verify, then parse.
685///
686/// **Replay is the scheme's known gap.** Jira's signature covers the body
687/// and nothing else — there is no timestamp header — so the verifier's
688/// replay tolerance has nothing to run on, and a captured delivery
689/// verifies forever. Terminate on TLS, rotate the per-tenant secret, and
690/// treat the secret as the only replay defence; that is a property of
691/// Jira's scheme, not a choice of this adapter.
692pub struct JiraStatusWebhook;
693
694impl StatusWebhook for JiraStatusWebhook {
695    fn kind(&self) -> &'static str {
696        "jira"
697    }
698
699    /// Constant-time compare, fail-closed on unreadable input: inherited
700    /// from core's [`WebhookVerifier`], never re-derived here.
701    fn verifier(&self) -> WebhookVerifier {
702        WebhookVerifier::new(ProviderScheme {
703            signature: "X-Hub-Signature",
704            encoding: SignatureEncoding::Hex,
705            prefix: Some("sha256="),
706            // Jira signs the body and nothing else — no timestamp header
707            // exists to bind. See the replay note above.
708            timestamp: None,
709        })
710    }
711
712    /// Reads one **verified** delivery. A `jira:issue_updated` or
713    /// `jira:issue_created` carrying a status maps onto a [`StatusUpdate`],
714    /// its URL rebuilt from the issue's `self` host; every other event, and
715    /// an update that names no status, is `Ok(None)` — verified silence,
716    /// not an error.
717    ///
718    /// # Errors
719    ///
720    /// [`InboundStatusError::Malformed`] when the verified body is not JSON
721    /// or a status event arrives without its issue key. Never
722    /// [`InboundStatusError::Signature`] — that answer belongs to
723    /// [`cratefield_core::receive_status`], before bytes are parsed.
724    fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
725        let event: Value = serde_json::from_slice(body)
726            .map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
727        match event["webhookEvent"].as_str() {
728            Some("jira:issue_updated" | "jira:issue_created") => {}
729            // A ping, a comment, a delete: verified, but carries no status.
730            _ => return Ok(None),
731        }
732        let Some(category) = event["issue"]["fields"]["status"]["statusCategory"]["key"].as_str()
733        else {
734            return Ok(None);
735        };
736        let Some(key) = event["issue"]["key"].as_str() else {
737            return Err(InboundStatusError::Malformed(
738                "a Jira status event carried no issue key".to_owned(),
739            ));
740        };
741        let state = match category {
742            "new" => TicketState::Open,
743            "indeterminate" => TicketState::InProgress,
744            "done" => TicketState::Resolved,
745            _ => TicketState::Unknown,
746        };
747        Ok(Some(StatusUpdate {
748            external_id: key.to_owned(),
749            state,
750            url: browse_url_from_self(event["issue"]["self"].as_str(), key),
751        }))
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn the_idem_label_is_a_prefixed_hash_of_the_key() {
761        let label = idem_label("outbox-42");
762        assert!(label.starts_with("cratefield-idem-"), "{label}");
763        let hex_part = label.trim_start_matches("cratefield-idem-");
764        assert_eq!(hex_part.len(), 32, "16 SHA-256 bytes in hex: {label}");
765        assert!(hex_part.bytes().all(|b| b.is_ascii_hexdigit()));
766        // Deterministic, and distinct per key.
767        assert_eq!(label, idem_label("outbox-42"));
768        assert_ne!(label, idem_label("outbox-43"));
769    }
770
771    #[test]
772    fn a_long_title_is_truncated_on_a_character_boundary() {
773        assert_eq!(truncate_summary("short"), "short");
774        // Multi-byte characters: the cut must land between characters, not
775        // inside one.
776        let long = "é".repeat(400);
777        assert_eq!(truncate_summary(&long).chars().count(), MAX_SUMMARY_CHARS);
778    }
779
780    #[test]
781    /// A body of only blanks is one empty paragraph, never `content: []` —
782    /// an empty `content` array is the shape Jira v3 rejects.
783    fn an_all_blank_body_still_yields_one_paragraph() {
784        for blank in ["", "   ", " \n\n \n"] {
785            let document = adf_document(blank);
786            assert_eq!(document["type"], "doc");
787            let content = document["content"].as_array().expect("content array");
788            assert_eq!(content.len(), 1, "{blank:?}: one paragraph, not []");
789            assert_eq!(content[0]["type"], "paragraph");
790            assert_eq!(content[0]["content"].as_array().map(Vec::len), Some(0));
791        }
792        // A body with real text is unchanged: one paragraph per block.
793        let content = adf_document("first\n\nsecond")["content"]
794            .as_array()
795            .expect("content array")
796            .clone();
797        assert_eq!(content.len(), 2);
798    }
799
800    #[test]
801    /// Stronger than redaction: there is no credential in the struct to
802    /// redact. The `email:api_token` pair arrives per call (#453), so a
803    /// `Debug` print — and anything else that reaches for the adapter's
804    /// fields — cannot leak one however the struct grows.
805    fn debug_of_the_adapter_holds_no_credential() {
806        let adapter = JiraCloud::new(Arc::new(NoHttp), Arc::new(cratefield_core::SystemClock));
807        let printed = format!("{adapter:?}");
808        assert!(printed.contains("JiraCloud"), "{printed}");
809        assert!(!printed.contains("api_token"), "{printed}");
810    }
811
812    /// The transport is never reached by the `Debug` test above.
813    struct NoHttp;
814
815    #[async_trait]
816    impl HttpClient for NoHttp {
817        async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
818            Err(HttpError::Transport("not used".to_owned()))
819        }
820    }
821}