Skip to main content

cratefield_adapter_linear/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use cratefield_core::{
9    Clock, Credential, Destination, Filed, HttpClient, HttpError, InboundStatusError,
10    ProviderScheme, SignatureEncoding, StatusUpdate, StatusWebhook, TicketComment, TicketDraft,
11    TicketState, TicketStatus, Tracker, TrackerError, WebhookVerifier, retry_after,
12};
13use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderValue};
14use http::{Request, StatusCode};
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17
18/// Linear's GraphQL endpoint — one URL for every operation, so the
19/// operation rides the body's `query` and the arguments its `variables`.
20const GRAPHQL_URL: &str = "https://api.linear.app/graphql";
21/// The prefix every Linear **personal API key** carries, and the only thing
22/// that tells a personal key from an OAuth access token in core's
23/// one-opaque-string [`Credential`]. A key is sent bare; a token behind
24/// `Bearer `.
25const PERSONAL_KEY_PREFIX: &str = "lin_api_";
26/// Linear's cap on `title`; a longer one is truncated on a character boundary
27/// rather than failing the create.
28const MAX_TITLE_CHARS: usize = 255;
29/// The idempotency stamp folded into the description and every comment body,
30/// so an at-least-once redelivery that files or notes twice leaves a
31/// duplicate a single text search can find. The crate's own prefix, so a
32/// tenant's text can never collide with it.
33const IDEM_PREFIX: &str = "cratefield-idem-";
34
35/// `mutation IssueCreate($input: IssueCreateInput!)` — one create, the
36/// issue's UUID and `url` read straight back off it.
37const ISSUE_CREATE: &str = "mutation IssueCreate($input: IssueCreateInput!) { \
38     issueCreate(input: $input) { success issue { id url } } }";
39/// `query Issue($id: String!)`. Linear's `issue(id:)` takes the UUID or an
40/// `ENG-123` identifier; this adapter files and reports by UUID, because
41/// `CommentCreateInput.issueId` is only documented to take the former.
42const ISSUE_QUERY: &str = "query Issue($id: String!) { issue(id: $id) { id url state { type } } }";
43/// `mutation CommentCreate($input: CommentCreateInput!)`.
44const COMMENT_CREATE: &str = "mutation CommentCreate($input: CommentCreateInput!) { commentCreate(input: $input) { success } }";
45
46/// `Tracker` over the Linear GraphQL API.
47///
48/// No `Debug` derive: kept explicit so a field added later cannot print
49/// `Debug` would print it wherever a log line or a panic message met the
50/// adapter.
51pub struct LinearTracker {
52    http: Arc<dyn HttpClient>,
53    /// Needed only to read the HTTP-date form of `Retry-After` (issue #278),
54    /// the same reason the GitHub Issues and Jira adapters hold one. A
55    /// constructor argument rather than a builder default so a deployment
56    /// that forgets it fails to compile instead of silently retrying a
57    /// date-form 429 immediately.
58    clock: Arc<dyn Clock>,
59    /// `None` — the default — posts to [`GRAPHQL_URL`]; a value overrides it
60    /// for tests pointed at a fake.
61    base: Option<String>,
62}
63
64impl std::fmt::Debug for LinearTracker {
65    /// Holds no credential to leak: the Linear key or OAuth token arrives
66    /// per call (#453).
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("LinearTracker")
69            .field("base", &self.base)
70            // The http and clock ports are struct fields too; nothing about
71            // either belongs in a log line.
72            .finish_non_exhaustive()
73    }
74}
75
76impl LinearTracker {
77    /// An adapter pointed at Linear's GraphQL API. It holds no credential:
78    /// which team, addressed under whose key, is tenant data and arrives
79    /// with each call (#453).
80    #[must_use]
81    pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
82        Self {
83            http,
84            clock,
85            base: None,
86        }
87    }
88
89    /// Overrides the URL every request is posted to — a fake in tests —
90    /// instead of Linear's own `https://api.linear.app/graphql`.
91    #[must_use]
92    pub fn with_base(mut self, base: impl Into<String>) -> Self {
93        self.base = Some(base.into());
94        self
95    }
96
97    /// The absolute URL requests are posted to.
98    fn endpoint(&self) -> &str {
99        self.base.as_deref().unwrap_or(GRAPHQL_URL)
100    }
101
102    /// The headers every Linear request carries: the tenant's
103    /// `Authorization`, and the JSON accept Linear answers with.
104    ///
105    /// A credential the HTTP layer cannot carry is refused here rather than
106    /// sent. Neither that nor an empty one is retryable: an empty one is
107    /// [`TrackerError::NotConfigured`], a malformed one
108    /// [`TrackerError::Rejected`].
109    fn request(cred: &Credential, uri: &str, body: Bytes) -> Result<Request<Bytes>, TrackerError> {
110        let secret = cred.expose();
111        if secret.is_empty() {
112            return Err(TrackerError::NotConfigured);
113        }
114        // Narrower than `HeaderValue::from_str` accepts, which admits a
115        // space and any byte above 127 as obs-text — neither of which a
116        // Linear key carries.
117        let malformed =
118            || TrackerError::Rejected("linear credential is not a legal header value".to_owned());
119        if !secret.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
120            return Err(malformed());
121        }
122        let value = HeaderValue::from_str(&auth_header(secret)).map_err(|_| malformed())?;
123        Request::builder()
124            .method(http::Method::POST)
125            .uri(uri)
126            .header(AUTHORIZATION, value)
127            .header(ACCEPT, "application/json")
128            .header(CONTENT_TYPE, "application/json")
129            .body(body)
130            .map_err(|_| TrackerError::Rejected("linear request could not be built".into()))
131    }
132
133    /// Sends one request and returns the body of a `2xx` answer. Anything
134    /// else — a transport failure or a non-2xx status — comes back as a
135    /// [`TrackerError`], already mapped.
136    async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
137        let response = self.http.send(request).await.map_err(|err: HttpError| {
138            match err {
139                // The port's SSRF vetting refused the destination URL (a
140                // scheme, userinfo, or a loopback / private / link-local /
141                // metadata destination, re-vetted per redirect hop): a config
142                // error, not weather.
143                HttpError::BlockedDestination(detail) => {
144                    TrackerError::Rejected(format!("linear destination refused: {detail}"))
145                }
146                // Any other transport failure is weather — retry. The
147                // provider's text cannot ride `Transient`, which carries
148                // only the delay, so it goes to the log the way the sibling
149                // adapters' does: an operator still gets to read it.
150                other => {
151                    tracing::warn!(provider = "linear", outcome = "failed", error = %other, "tracker outcome");
152                    TrackerError::Transient { retry_after: None }
153                }
154            }
155        })?;
156        let status = response.status();
157        let text = String::from_utf8_lossy(response.body()).to_string();
158        if !status.is_success() {
159            // One parser for both `Retry-After` forms (issue #214/#278); the
160            // date form needs the clock this adapter is constructed with.
161            let delay = retry_after(response.headers(), self.clock.as_ref());
162            let error = Self::map_status(status, &text, delay);
163            tracing::warn!(
164                provider = "linear",
165                code = status.as_u16(),
166                outcome = "failed",
167                detail = %text,
168                error = %error,
169                "tracker outcome"
170            );
171            return Err(error);
172        }
173        Ok(text)
174    }
175
176    /// Maps a Linear HTTP status onto the port's error taxonomy — the same
177    /// table the GitHub Issues and Jira adapters apply: `401`/`403` say the
178    /// key is wrong or powerless; the rest of the `4xx` family is a fact
179    /// about our request; `429` and the `5xx` family are the provider having
180    /// a moment.
181    fn map_status(
182        status: StatusCode,
183        body: &str,
184        retry_after: Option<std::time::Duration>,
185    ) -> TrackerError {
186        match status {
187            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
188            // Must precede the `4xx` arm — 429 *is* a client error.
189            // (`retry_after` reads both header forms and answers `None` when
190            // no header came, so a bare 500 stays unscheduled.)
191            status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
192                TrackerError::Transient { retry_after }
193            }
194            status if status.is_client_error() => {
195                TrackerError::Rejected(format!("linear {status}: {}", flatten_errors(body)))
196            }
197            _ => TrackerError::Transient { retry_after },
198        }
199    }
200
201    /// Posts one GraphQL operation and returns its `data`.
202    ///
203    /// The GraphQL-specific half of "is this a failure": Linear answers a
204    /// failed operation with a **`200`** whose body carries a top-level
205    /// `errors` array, so a status check alone would read every rejection as
206    /// a success.
207    ///
208    /// `accepted` says whether this operation's own payload reports that it
209    /// took effect. A mutation that did — `issueCreate { success: true }` —
210    /// wins over a non-fatal `errors` array, which Linear attaches to partial
211    /// successes; failing such a create would file a duplicate on the next
212    /// attempt. The errors are mapped only when nothing accepted the call.
213    async fn graphql(
214        &self,
215        cred: &Credential,
216        query: &str,
217        variables: serde_json::Value,
218        accepted: impl Fn(&Payload) -> bool,
219    ) -> Result<Payload, TrackerError> {
220        let body = serde_json::json!({ "query": query, "variables": variables });
221        let json =
222            serde_json::to_vec(&body).map_err(|_| TrackerError::Transient { retry_after: None })?;
223        let request = Self::request(cred, self.endpoint(), Bytes::from(json))?;
224        let text = self.send_checked(request).await?;
225        // Every response shape is read through a typed struct, never by
226        // indexing a `Value`: indexing where an object was expected panics,
227        // and these bodies come from the network. A 2xx we cannot read is
228        // not one we can trust — transient, and let the caller retry.
229        let response: Response = serde_json::from_str(&text)
230            .map_err(|_| TrackerError::Transient { retry_after: None })?;
231        if response.errors.is_empty() || accepted(&response.data) {
232            return Ok(response.data);
233        }
234        Err(map_errors(&response.errors))
235    }
236}
237
238#[async_trait]
239impl Tracker for LinearTracker {
240    /// One `issueCreate` mutation. `title` is the draft's own, truncated to
241    /// Linear's cap; `description` is the Markdown body verbatim (Linear
242    /// renders Markdown, so there is no document format to translate) plus a
243    /// footnote carrying what the port's shape has and Linear's input has no
244    /// field for: the labels, the environment, the severity, the stamp.
245    ///
246    /// **`external_id` is the issue's UUID**, the only id
247    /// `CommentCreateInput.issueId` is documented to take. **`Filed` is not
248    /// idempotent:** Linear offers no stamp to search an issue by, so the
249    /// at-least-once outbox may file twice; see the README.
250    async fn file(
251        &self,
252        dest: &Destination,
253        cred: &Credential,
254        draft: &TicketDraft,
255    ) -> Result<Filed, TrackerError> {
256        let team = team_of(dest)?;
257        let input = serde_json::json!({
258            "teamId": team,
259            "title": truncate_title(&draft.title),
260            "description": description(draft),
261        });
262        let data = self
263            .graphql(
264                cred,
265                ISSUE_CREATE,
266                serde_json::json!({ "input": input }),
267                |data| {
268                    data.issue_create
269                        .as_ref()
270                        .is_some_and(|made| made.success == Some(true))
271                },
272            )
273            .await?;
274        let created = data
275            .issue_create
276            .as_ref()
277            // Linear's `success: false` with no `errors` is the other shape
278            // of a refusal; both must fail the call.
279            .filter(|made| made.success != Some(false))
280            .ok_or_else(|| {
281                TrackerError::Rejected("linear issueCreate reported success: false".to_owned())
282            })?;
283        // A 2xx carrying no issue is not a create this adapter can trust:
284        // transient, and let the next attempt settle it.
285        let issue = created
286            .issue
287            .as_ref()
288            .ok_or(TrackerError::Transient { retry_after: None })?;
289        let external_id = issue
290            .id
291            .as_deref()
292            .ok_or(TrackerError::Transient { retry_after: None })?;
293        let url = issue
294            .url
295            .as_deref()
296            .ok_or(TrackerError::Transient { retry_after: None })?;
297        tracing::info!(
298            provider = "linear",
299            outcome = "created",
300            idempotency = %draft.idempotency_key,
301            issue = %external_id,
302            "tracker outcome"
303        );
304        Ok(Filed {
305            external_id: external_id.to_owned(),
306            url: url.to_owned(),
307        })
308    }
309
310    /// One `issue(id:)` query, mapping `state.type` — Linear's own
311    /// workflow-type rollup, the field that survives every custom workflow a
312    /// tenant renames — onto the port's five.
313    async fn status(
314        &self,
315        dest: &Destination,
316        cred: &Credential,
317        external_id: &str,
318    ) -> Result<TicketStatus, TrackerError> {
319        team_of(dest)?;
320        validate_external_id(external_id)?;
321        let data = self
322            .graphql(
323                cred,
324                ISSUE_QUERY,
325                serde_json::json!({ "id": external_id }),
326                |_| false,
327            )
328            .await?;
329        let issue = data.issue.as_ref().ok_or_else(|| {
330            // `issue: null` is Linear's not-found — the same fact a bare
331            // `404` carries, and the same answer: `Rejected`, since a retry
332            // of a missing issue changes nothing. `Unknown` would read as
333            // "the state is one this port cannot name", a different thing.
334            TrackerError::Rejected(format!("linear has no issue {external_id}"))
335        })?;
336        Ok(TicketStatus {
337            external_id: external_id.to_owned(),
338            state: state_of(issue.state.as_ref().and_then(|state| state.kind.as_deref())),
339            // Linear's own URL, or none: its `linear.app/{workspace}/issue/…`
340            // form needs the workspace slug, which nothing here carries, and
341            // a guess that would 404 is worse than the port's `None`.
342            url: issue.url.clone(),
343        })
344    }
345
346    /// One `commentCreate` mutation: the note's Markdown verbatim plus, when
347    /// it carries one, a trailing line linking it. No search-before-write, so
348    /// a redelivery may duplicate the note — but the key rides the body as
349    /// the same `cratefield-idem-…` stamp, so any duplicate is findable.
350    async fn comment(
351        &self,
352        dest: &Destination,
353        cred: &Credential,
354        external_id: &str,
355        comment: &TicketComment,
356    ) -> Result<(), TrackerError> {
357        team_of(dest)?;
358        validate_external_id(external_id)?;
359        let body = match comment.link.as_deref() {
360            // Linear renders Markdown, so the link is one markdown line;
361            // only an http(s) link with no control character in it is worth
362            // one, decided before any request.
363            Some(link)
364                if (link.starts_with("https://") || link.starts_with("http://"))
365                    && !link.chars().any(char::is_control) =>
366            {
367                format!("{}\n\n{link}", comment.body_markdown.trim_end())
368            }
369            Some(_) => {
370                return Err(TrackerError::Rejected(
371                    "linear comment link must be an http(s) URL with no control characters"
372                        .to_owned(),
373                ));
374            }
375            None => comment.body_markdown.clone(),
376        };
377        let input = serde_json::json!({
378            "issueId": external_id,
379            "body": format!("{body}\n\n---\n\n{IDEM_PREFIX}{}", idem_hash(&comment.idempotency_key)),
380        });
381        let data = self
382            .graphql(
383                cred,
384                COMMENT_CREATE,
385                serde_json::json!({ "input": input }),
386                |data| {
387                    data.comment_create
388                        .as_ref()
389                        .is_some_and(|made| made.success == Some(true))
390                },
391            )
392            .await?;
393        if data
394            .comment_create
395            .as_ref()
396            .is_none_or(|made| made.success == Some(false))
397        {
398            return Err(TrackerError::Rejected(
399                "linear commentCreate reported success: false".to_owned(),
400            ));
401        }
402        tracing::info!(
403            provider = "linear",
404            outcome = "commented",
405            idempotency = %comment.idempotency_key,
406            issue = external_id,
407            "tracker outcome"
408        );
409        Ok(())
410    }
411}
412
413/// One GraphQL response, read as a struct: `serde` fails on a body whose
414/// shape is not this one, which keeps a hostile or simply odd `200` from
415/// turning into a panic.
416#[derive(Deserialize, Default)]
417struct Response {
418    #[serde(default)]
419    errors: Vec<GraphqlError>,
420    #[serde(default)]
421    data: Payload,
422}
423
424/// The `data` object. Every field is optional: which one an operation fills
425/// is the operation's own business, and a missing one is a `2xx` read as an
426/// answer this adapter cannot trust — never as an index panic.
427#[derive(Deserialize, Default)]
428struct Payload {
429    #[serde(rename = "issueCreate")]
430    issue_create: Option<Mutation>,
431    #[serde(rename = "commentCreate")]
432    comment_create: Option<Mutation>,
433    issue: Option<Issue>,
434}
435
436/// One mutation's `success` and, for a create, the issue it made.
437#[derive(Deserialize, Default)]
438struct Mutation {
439    #[serde(default)]
440    success: Option<bool>,
441    #[serde(default)]
442    issue: Option<Issue>,
443}
444
445/// The slice of an issue this adapter reads.
446#[derive(Deserialize)]
447struct Issue {
448    #[serde(default)]
449    id: Option<String>,
450    #[serde(default)]
451    url: Option<String>,
452    #[serde(default)]
453    state: Option<WorkflowState>,
454}
455
456/// A workflow state, read for its `type` — the rollup, not the name, which a
457/// tenant renames freely.
458#[derive(Deserialize)]
459struct WorkflowState {
460    #[serde(rename = "type", default)]
461    kind: Option<String>,
462}
463
464/// One entry of a GraphQL `errors` array, and the `extensions` it carries for
465/// its machine-readable code.
466#[derive(Deserialize, Default)]
467struct GraphqlError {
468    #[serde(default)]
469    message: Option<String>,
470    #[serde(default)]
471    extensions: Option<GraphqlErrorExtensions>,
472}
473
474#[derive(Deserialize, Default)]
475struct GraphqlErrorExtensions {
476    #[serde(default)]
477    code: Option<String>,
478}
479
480/// Linear's inbound status webhook, over core's [`ProviderScheme`]:
481/// `Linear-Signature: <hex>`, HMAC-SHA256 over the raw body with no prefix,
482/// read by [`cratefield_core::receive_status`] — verify, then parse.
483///
484/// **Replay protection is not provided here.** Linear signs the raw body
485/// alone — its `webhookTimestamp` (ms) rides *inside* that body, not in a
486/// header — and core's [`ProviderScheme`] can only bind a timestamp *header*
487/// into the signed payload, so the verifier's replay tolerance has nothing to
488/// run on and a captured delivery verifies forever, exactly as with Jira.
489/// This type parses the body and nothing else; a caller wanting a freshness
490/// window must read `webhookTimestamp` (and the delivery's `webhookId`) out of
491/// the already-verified body itself. Terminate on TLS and rotate the
492/// per-tenant secret either way.
493pub struct LinearStatusWebhook;
494
495impl StatusWebhook for LinearStatusWebhook {
496    fn kind(&self) -> &'static str {
497        "linear"
498    }
499
500    /// Constant-time compare, fail-closed on unreadable input: inherited
501    /// from core's [`WebhookVerifier`], never re-derived here.
502    fn verifier(&self) -> WebhookVerifier {
503        WebhookVerifier::new(ProviderScheme {
504            signature: "Linear-Signature",
505            encoding: SignatureEncoding::Hex,
506            // Linear sends bare hex — no `sha256=` prefix, unlike GitHub.
507            prefix: None,
508            // Linear's timestamp is in the body, not a header, so it cannot
509            // be bound into the signed payload here. See the replay note.
510            timestamp: None,
511        })
512    }
513
514    /// Reads one **verified** delivery. An `update` on `"type": "Issue"`
515    /// that carries a state **and** names the state it came from
516    /// (`updatedFrom.stateId`) maps onto a [`StatusUpdate`] keyed by the same
517    /// issue UUID [`Tracker::file`] returns; every other event, and an update
518    /// that touched no state, is `Ok(None)` — verified silence, not an error.
519    ///
520    /// # Errors
521    ///
522    /// [`InboundStatusError::Malformed`] when the verified body is not JSON or
523    /// not an event of the shape Linear sends, or a state change arrives with
524    /// no id to key it by. Never [`InboundStatusError::Signature`] — that
525    /// belongs to [`cratefield_core::receive_status`], before parsing.
526    fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
527        // Typed, so a body that is a JSON array or a bare string is
528        // `Malformed` rather than a panic on a missing field.
529        let event: Event = serde_json::from_slice(body)
530            .map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
531        // A comment, a project, an issue *created*: verified, but not a
532        // state change we were told to trust.
533        if event.kind.as_deref() != Some("Issue") || event.action.as_deref() != Some("update") {
534            return Ok(None);
535        }
536        let data = event.data.as_ref();
537        let Some(state_type) = data
538            .and_then(|data| data.state.as_ref())
539            .and_then(|state| state.kind.as_deref())
540        else {
541            return Ok(None);
542        };
543        // `updatedFrom.stateId` is Linear's own "this field changed" marker:
544        // without it the update touched something else (an assignee, a
545        // title) and the state here is the one it already had.
546        if !event.state_changed() {
547            return Ok(None);
548        }
549        let external_id = data.and_then(|data| data.id.as_deref()).ok_or_else(|| {
550            InboundStatusError::Malformed("a Linear state change carried no issue id".to_owned())
551        })?;
552        Ok(Some(StatusUpdate {
553            external_id: external_id.to_owned(),
554            state: state_of(Some(state_type)),
555            url: event.url.clone(),
556        }))
557    }
558}
559
560/// One inbound webhook event, in the shape [`LinearStatusWebhook::parse`]
561/// reads. Linear puts `updatedFrom` **beside** `data`, not inside it: the
562/// fields this delivery changed.
563#[derive(Deserialize)]
564struct Event {
565    #[serde(rename = "type", default)]
566    kind: Option<String>,
567    #[serde(default)]
568    action: Option<String>,
569    #[serde(default)]
570    url: Option<String>,
571    #[serde(default)]
572    data: Option<EventData>,
573    #[serde(rename = "updatedFrom", default)]
574    updated_from: Option<UpdatedFrom>,
575}
576
577impl Event {
578    /// Whether the **state** is what changed: the presence of
579    /// `updatedFrom.stateId`, which is the whole of its meaning — its value
580    /// is never read.
581    fn state_changed(&self) -> bool {
582        self.updated_from
583            .as_ref()
584            .is_some_and(|from| from.state_id.is_some())
585    }
586}
587
588/// The `data` of an issue event.
589#[derive(Deserialize)]
590struct EventData {
591    #[serde(default)]
592    id: Option<String>,
593    #[serde(default)]
594    state: Option<WorkflowState>,
595}
596
597/// Linear's `updatedFrom`, present only on an update that changed something.
598#[derive(Deserialize)]
599struct UpdatedFrom {
600    #[serde(rename = "stateId", default)]
601    state_id: Option<String>,
602}
603
604/// The `Authorization` value for Linear's two credential shapes.
605///
606/// Core's [`Credential`] is one opaque string, so this is decided by Linear's
607/// own documented prefix: a **personal API key** (always `lin_api_…`) is sent
608/// **bare**, with no `Bearer` — Linear rejects the prefix on personal keys —
609/// while anything else is an OAuth **access token** and takes `Bearer `.
610///
611/// The returned `String` is a copy the HTTP layer owns; only the
612/// [`Credential`]'s own buffer is zeroised on drop. The token appears here,
613/// in that header, and nowhere else — never in an error, a log or a `Debug`.
614fn auth_header(secret: &str) -> String {
615    if secret.starts_with(PERSONAL_KEY_PREFIX) {
616        secret.to_owned()
617    } else {
618        format!("Bearer {secret}")
619    }
620}
621
622/// The GraphQL `errors` array, mapped onto the port's taxonomy.
623///
624/// Linear signals a rate limit with `RATELIMITED` in
625/// `errors[].extensions.code` and **HTTP 200**, so a status-only check would
626/// file a throttled create as a success. Authentication comes first: Linear
627/// answers an expired OAuth token with `AUTHENTICATION`, and a team the key
628/// cannot reach with `AUTHORIZATION`.
629fn map_errors(errors: &[GraphqlError]) -> TrackerError {
630    let code = |wanted: &[&str]| {
631        errors
632            .iter()
633            .filter_map(|error| error.extensions.as_ref())
634            .filter_map(|extensions| extensions.code.as_deref())
635            .any(|code| wanted.contains(&code))
636    };
637    if code(&["AUTHENTICATION", "AUTHORIZATION", "INVALID_TOKEN"]) {
638        return TrackerError::Unauthorized;
639    }
640    if code(&[
641        "RATELIMITED",
642        "INTERNAL_SERVER_ERROR",
643        "SERVICE_UNAVAILABLE",
644        "TIMEOUT",
645    ]) {
646        // Linear names no `Retry-After` alongside a 200, so there is no delay
647        // to schedule; the caller's own backoff is the answer.
648        return TrackerError::Transient { retry_after: None };
649    }
650    TrackerError::Rejected(format!("linear graphql error: {}", join_messages(errors)))
651}
652
653/// Every `errors[].message` flattened to one line, or a stand-in when none
654/// came. It rides [`TrackerError::Rejected`], whose `Display` scrubs it — a
655/// Linear error can quote tenant data.
656fn join_messages(errors: &[GraphqlError]) -> String {
657    let detail = errors
658        .iter()
659        .filter_map(|error| error.message.as_deref())
660        .filter(|message| !message.is_empty())
661        .collect::<Vec<_>>()
662        .join("; ");
663    if detail.is_empty() {
664        "the response carried no message".to_owned()
665    } else {
666        detail
667    }
668}
669
670/// The human text inside an error **body**, whether or not it is a GraphQL
671/// envelope: its `errors[].message` array flattened to one line, or the body
672/// quoted whole when it is not JSON or carries no message.
673fn flatten_errors(body: &str) -> String {
674    match serde_json::from_str::<Response>(body) {
675        Ok(response) if !response.errors.is_empty() => join_messages(&response.errors),
676        _ => body.to_owned(),
677    }
678}
679
680/// The team a [`Destination::Linear`] names, or the rejection for any other
681/// destination — decided before any request, the way the GitHub adapter
682/// refuses a webhook.
683///
684/// Linear's `issueCreate` takes `teamId`, the team's UUID, not its key, so
685/// that is what a tenant configures here.
686fn team_of(dest: &Destination) -> Result<&str, TrackerError> {
687    match dest {
688        Destination::Linear { team } => {
689            validate_team(team)?;
690            Ok(team.as_str())
691        }
692        other => Err(TrackerError::unsupported_destination(other)),
693    }
694}
695
696/// A team id must be a UUID or a team key: ASCII alphanumerics, `-` and `_`,
697/// nothing else, and not empty or absurdly long.
698///
699/// This is **fail-fast input checking, not injection defence** — the value
700/// rides as a GraphQL *variable*, which serde JSON-encodes, so it cannot
701/// reshape the operation whatever it holds. What it buys is naming a
702/// misconfigured team at the call site instead of surfacing as an opaque
703/// GraphQL rejection.
704fn validate_team(team: &str) -> Result<(), TrackerError> {
705    let valid = !team.is_empty()
706        && team.len() <= 64
707        && team
708            .chars()
709            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
710    if valid {
711        Ok(())
712    } else {
713        Err(TrackerError::Rejected(
714            "linear team must be a team UUID or key (letters, digits, '-' and '_')".to_owned(),
715        ))
716    }
717}
718
719/// An external id must be one Linear's `issue(id:)` accepts — the UUID
720/// [`Tracker::file`] returns, or an `ENG-123` identifier — before it is sent.
721/// Same fail-fast rationale as [`validate_team`]: it rides as a GraphQL
722/// variable, so it names a malformed value at the call site.
723fn validate_external_id(external_id: &str) -> Result<(), TrackerError> {
724    let is_uuid = external_id.len() == 36
725        && external_id
726            .chars()
727            .all(|c| c.is_ascii_hexdigit() || c == '-')
728        && external_id.matches('-').count() == 4;
729    let is_identifier = match external_id.split_once('-') {
730        Some((key, number)) => {
731            !key.is_empty()
732                && !number.is_empty()
733                && number.bytes().all(|b| b.is_ascii_digit())
734                && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
735        }
736        None => false,
737    };
738    if is_uuid || is_identifier {
739        Ok(())
740    } else {
741        Err(TrackerError::Rejected(
742            "linear external id must be an issue UUID or an ENG-123 identifier".to_owned(),
743        ))
744    }
745}
746
747/// The description a filed issue carries: the draft's Markdown verbatim
748/// (Linear renders Markdown natively, so unlike Jira there is no document
749/// format to translate) plus a footnote with everything the port's shape has
750/// and `IssueCreateInput` has no field for.
751///
752/// **The labels are folded in here rather than mapped to `labelIds`.** Linear
753/// addresses a label by its UUID, and the port's `labels` are free-form text
754/// a tenant chose — guessing a UUID from a label name would attach a
755/// *different* tenant's label, or none, so the text rides the body where it is
756/// at least findable.
757fn description(draft: &TicketDraft) -> String {
758    let mut facts = vec![format!("severity/{}", draft.severity.name())];
759    facts.extend(draft.labels.iter().map(|label| format!("label/{label}")));
760    if let Some(environment) = &draft.environment {
761        facts.push(format!("environment/{environment}"));
762    }
763    format!(
764        "{}\n\n---\n\n{}\n\n{IDEM_PREFIX}{}",
765        draft.body_markdown.trim_end(),
766        facts.join(" · "),
767        idem_hash(&draft.idempotency_key)
768    )
769}
770
771/// The stamp tying an issue or a note to an outbox idempotency key:
772/// [`IDEM_PREFIX`] plus the first 16 bytes of SHA-256 of the key, hex —
773/// hashed, because the body is text a Linear search greps for and a caller's
774/// key may quote tenant data.
775fn idem_hash(idempotency_key: &str) -> String {
776    hex::encode(&Sha256::digest(idempotency_key.as_bytes())[..16])
777}
778
779/// Linear caps `title` at [`MAX_TITLE_CHARS`]; a longer title is truncated on
780/// a character boundary — a split character is something serde's JSON writer
781/// would refuse to emit.
782fn truncate_title(title: &str) -> &str {
783    match title.char_indices().nth(MAX_TITLE_CHARS) {
784        Some((at, _)) => &title[..at],
785        None => title,
786    }
787}
788
789/// Maps Linear's `state.type` — its workflow-type rollup, which survives
790/// every custom workflow a tenant renames — onto the port's five. A type the
791/// port cannot name is said so rather than guessed into one of the four.
792/// Canceled is done, not done-and-fixed: `Closed` is the port's "will not be
793/// reopened".
794fn state_of(state_type: Option<&str>) -> TicketState {
795    match state_type {
796        // Filed, triaged, backlogged or scheduled, but nobody started it.
797        Some("triage" | "backlog" | "unstarted") => TicketState::Open,
798        Some("started") => TicketState::InProgress,
799        Some("completed") => TicketState::Resolved,
800        Some("canceled") => TicketState::Closed,
801        _ => TicketState::Unknown,
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn a_long_title_is_truncated_on_a_character_boundary() {
811        assert_eq!(truncate_title("short"), "short");
812        // Multi-byte characters: the cut must land between characters, not
813        // inside one.
814        let long = "é".repeat(400);
815        assert_eq!(truncate_title(&long).chars().count(), MAX_TITLE_CHARS);
816    }
817}