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}