Skip to main content

cratefield_adapter_github_issues/
lib.rs

1//! `cratefield-adapter-github-issues`: the [`Tracker`] port over the GitHub
2//! Issues REST API (issue #432). Uses the runtime's [`HttpClient`] and
3//! [`Clock`] ports — no vendor SDK — so the same adapter runs on Workers and
4//! natively (ADR 0002: the port lives in core, the vendor client here).
5//!
6//! **At-least-once means search-before-create.** The outbox may call
7//! [`Tracker::file`] twice for one [`TicketDraft`], so the adapter stamps
8//! every issue body with an invisible HTML comment carrying the draft's
9//! idempotency key and looks for its own stamp before creating. A lookup
10//! that *fails* fails the whole call — creating after an unreliable dedupe
11//! check is exactly the double-file the check exists to prevent.
12
13#![doc = include_str!("../README.md")]
14#![forbid(unsafe_code)]
15
16use async_trait::async_trait;
17use bytes::Bytes;
18use cratefield_core::{
19    Clock, Credential, Destination, Filed, HttpClient, HttpError, TicketDraft, TicketState,
20    TicketStatus, Tracker, TrackerError, retry_after,
21};
22use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
23use http::{Request, StatusCode};
24use std::fmt::Write as _;
25use std::sync::Arc;
26
27const DEFAULT_BASE: &str = "https://api.github.com";
28/// GitHub's current REST API version, pinned the way their docs say to.
29const API_VERSION: &str = "2022-11-28";
30/// GitHub rejects requests without a `User-Agent`.
31const CLIENT_USER_AGENT: &str = "cratefield-adapter-github-issues";
32/// How far back the fast-retry scan reaches. A page of the 100 most recent
33/// issues covers the window where the search index has not caught up yet —
34/// the common redelivery is seconds, not hours, after the first attempt.
35const RECENT_ISSUES_PAGE: &str = "state=all&per_page=100";
36
37/// `Tracker` over the GitHub Issues REST API.
38///
39/// No `Debug` derive: kept explicit so a field added later cannot print
40/// `Debug` would print it wherever a log line or a panic message met the
41/// adapter.
42pub struct GitHubIssues {
43    http: Arc<dyn HttpClient>,
44    /// Needed only to read the HTTP-date form of `Retry-After` (issue #278),
45    /// the same reason `Resend` holds one. A constructor argument rather
46    /// than a builder default so a deployment that forgets it fails to
47    /// compile instead of silently retrying a date-form 429 immediately.
48    clock: Arc<dyn Clock>,
49    /// The API root. Defaults to `https://api.github.com`;
50    /// [`GitHubIssues::with_base`] overrides it for GitHub Enterprise Server
51    /// and for tests pointed at a fake.
52    base: String,
53}
54
55impl std::fmt::Debug for GitHubIssues {
56    /// Holds no credential to leak: the token arrives per call (#453).
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("GitHubIssues")
59            .field("base", &self.base)
60            // The http and clock ports are struct fields too; nothing about
61            // either belongs in a log line.
62            .finish_non_exhaustive()
63    }
64}
65
66impl GitHubIssues {
67    /// An adapter pointed at `https://api.github.com`. It holds no token:
68    /// which repository, under whose installation, is tenant data and
69    /// arrives with each `file` or `status` call (#453).
70    pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
71        Self {
72            http,
73            clock,
74            base: DEFAULT_BASE.to_owned(),
75        }
76    }
77
78    /// Points the adapter at another API root — GitHub Enterprise Server, or
79    /// a fake in tests — instead of `https://api.github.com`.
80    #[must_use]
81    pub fn with_base(mut self, base: impl Into<String>) -> Self {
82        self.base = base.into();
83        self
84    }
85
86    /// The headers every GitHub request carries: the token, the pinned API
87    /// version, and the `User-Agent` GitHub refuses requests without.
88    /// Takes the credential per call: whose token files into which
89    /// tracker is tenant data (#453), so the adapter holds none.
90    fn request(cred: &Credential, method: http::Method, uri: String) -> http::request::Builder {
91        Request::builder()
92            .method(method)
93            .uri(uri)
94            .header(AUTHORIZATION, format!("Bearer {}", cred.expose()))
95            .header(ACCEPT, "application/vnd.github+json")
96            .header("X-GitHub-Api-Version", API_VERSION)
97            .header(USER_AGENT, CLIENT_USER_AGENT)
98    }
99
100    /// Sends one request and returns the body of a `2xx` answer. Anything
101    /// else — a transport failure or a non-2xx status — comes back as a
102    /// [`TrackerError`], already mapped, so a lookup caller cannot fall
103    /// through a failed check into a create.
104    async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
105        let response = self.http.send(request).await.map_err(|err: HttpError| {
106            match err {
107                // The port's SSRF vetting refused the destination URL
108                // (a scheme, userinfo, or a loopback / private / link-local /
109                // metadata destination, re-vetted per redirect hop). That is
110                // a config error, not weather: retrying will not fix it.
111                HttpError::BlockedDestination(detail) => {
112                    TrackerError::Rejected(format!("github issues destination refused: {detail}"))
113                }
114                // Any other transport failure is weather — retry. The
115                // provider's text cannot ride `Transient`, which carries
116                // only the delay, so it goes to the log the way the resend
117                // adapter's does: an operator still gets to read it.
118                other => {
119                    tracing::warn!(
120                        provider = "github-issues",
121                        outcome = "failed",
122                        error = %other,
123                        "tracker outcome"
124                    );
125                    TrackerError::Transient { retry_after: None }
126                }
127            }
128        })?;
129        let status = response.status();
130        let text = String::from_utf8_lossy(response.body()).to_string();
131        if !status.is_success() {
132            // One parser for both `Retry-After` forms (issue #214/#278); the
133            // date form needs the clock this adapter is constructed with.
134            let delay = retry_after(response.headers(), self.clock.as_ref());
135            let error = Self::map_status(status, &text, delay);
136            tracing::warn!(
137                provider = "github-issues",
138                code = status.as_u16(),
139                outcome = "failed",
140                detail = %text,
141                error = %error,
142                "tracker outcome"
143            );
144            return Err(error);
145        }
146        Ok(text)
147    }
148
149    /// Maps a GitHub status onto the port's error taxonomy. The table is the
150    /// issue's: `401`/`403` say the token is wrong or powerless;
151    /// `404` says the repository is gone or invisible to the token, and
152    /// `422` says GitHub refused the ticket itself — neither is fixed by
153    /// retrying; `429` and the `5xx` family are the provider having a
154    /// moment. For the rest, the defensible rule is that a `4xx` is a fact
155    /// about *our* request (`Rejected`) and nothing else is: a stray `3xx`
156    /// a proxy produced is weather, not a verdict on the draft.
157    fn map_status(
158        status: StatusCode,
159        body: &str,
160        retry_after: Option<std::time::Duration>,
161    ) -> TrackerError {
162        let detail = match serde_json::from_str::<ErrorResponse>(body) {
163            Ok(parsed) if !parsed.message.is_empty() => parsed.message,
164            _ => body.to_string(),
165        };
166        match status {
167            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
168            StatusCode::NOT_FOUND | StatusCode::UNPROCESSABLE_ENTITY => {
169                TrackerError::Rejected(format!("github issues {status}: {detail}"))
170            }
171            // `429` and the `5xx` family are the provider having a moment:
172            // retry, and not before `retry_after` where the provider named
173            // one. (`retry_after` reads both header forms and answers `None`
174            // when no header came, so a bare 500 stays unscheduled.)
175            status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
176                let _ = (status, detail);
177                TrackerError::Transient { retry_after }
178            }
179            // The rest of the `4xx` family reads the same way as `404` and
180            // `422`: a fact about our request, which a retry will not fix.
181            status if status.is_client_error() => {
182                TrackerError::Rejected(format!("github issues {status}: {detail}"))
183            }
184            // A stray `3xx` an unfollowing proxy produced is weather, not a
185            // verdict on the draft.
186            _ => TrackerError::Transient { retry_after },
187        }
188    }
189
190    /// The dedupe lookups, in order. `Err` is fail-closed: it propagates out
191    /// of `file` instead of falling through to the create, because a ticket
192    /// created after an *unreliable* dedupe check is exactly the duplicate
193    /// the check exists to prevent.
194    async fn find_existing(
195        &self,
196        cred: &Credential,
197        owner: &str,
198        repo: &str,
199        marker: &str,
200    ) -> Result<Option<Filed>, TrackerError> {
201        // The search index covers the whole repository's history, so it is
202        // the general answer. It is also eventually consistent: a retry that
203        // lands seconds after the first attempt can predate it, which is
204        // what the recent-issues page below is for.
205        if let Some(existing) = self.search_existing(cred, owner, repo, marker).await? {
206            return Ok(Some(existing));
207        }
208        self.recent_existing(cred, owner, repo, marker).await
209    }
210
211    /// The search-index half of the dedupe check: the quoted marker text
212    /// plus `repo:`/`is:issue` qualifiers, and — because search
213    /// token-matches and must not be trusted blind — a verify that the
214    /// marker really appears in a hit's body before accepting it.
215    async fn search_existing(
216        &self,
217        cred: &Credential,
218        owner: &str,
219        repo: &str,
220        marker: &str,
221    ) -> Result<Option<Filed>, TrackerError> {
222        let query = format!("\"{marker}\" repo:{owner}/{repo} is:issue");
223        let uri = format!("{}/search/issues?q={}", self.base, percent_encode(&query));
224        let request = Self::request(cred, http::Method::GET, uri)
225            .body(Bytes::new())
226            .map_err(|_| TrackerError::Transient { retry_after: None })?;
227        let text = self.send_checked(request).await?;
228        let parsed: SearchResponse = serde_json::from_str(&text)
229            .map_err(|_| TrackerError::Transient { retry_after: None })?;
230        Ok(parsed
231            .items
232            .iter()
233            .find(|issue| issue.carries(marker))
234            .and_then(IssueRef::filed))
235    }
236
237    /// The recent-issues half of the dedupe check, for the fast-retry window
238    /// where the search index has not caught up. The list endpoint also
239    /// returns pull requests among the "issues"; `IssueRef::carries` skips
240    /// those.
241    async fn recent_existing(
242        &self,
243        cred: &Credential,
244        owner: &str,
245        repo: &str,
246        marker: &str,
247    ) -> Result<Option<Filed>, TrackerError> {
248        let uri = format!(
249            "{}/repos/{owner}/{repo}/issues?{RECENT_ISSUES_PAGE}",
250            self.base
251        );
252        let request = Self::request(cred, http::Method::GET, uri)
253            .body(Bytes::new())
254            .map_err(|_| TrackerError::Transient { retry_after: None })?;
255        let text = self.send_checked(request).await?;
256        let parsed: Vec<IssueRef> = serde_json::from_str(&text)
257            .map_err(|_| TrackerError::Transient { retry_after: None })?;
258        Ok(parsed
259            .iter()
260            .find(|issue| issue.carries(marker))
261            .and_then(IssueRef::filed))
262    }
263}
264
265#[async_trait]
266impl Tracker for GitHubIssues {
267    async fn file(
268        &self,
269        dest: &Destination,
270        cred: &Credential,
271        draft: &TicketDraft,
272    ) -> Result<Filed, TrackerError> {
273        let (owner, repo) = repo_of(dest)?;
274        let marker = idem_marker(&draft.idempotency_key);
275
276        // Search before creating. The outbox is at-least-once, so this WILL
277        // be called twice for one draft; and the `?` here is the fail-closed
278        // rule — a lookup that itself fails ends the call.
279        if let Some(existing) = self.find_existing(cred, owner, repo, &marker).await? {
280            tracing::info!(
281                provider = "github-issues",
282                outcome = "deduplicated",
283                idempotency = %draft.idempotency_key,
284                issue = %existing.external_id,
285                "tracker outcome"
286            );
287            return Ok(existing);
288        }
289
290        // The marker rides in the body as an HTML comment: invisible when
291        // GitHub renders the issue, greppable by the dedupe check.
292        let body = format!("{}\n\n{marker}", draft.body_markdown);
293        let payload = NewIssue {
294            title: &draft.title,
295            body: &body,
296            labels: draft.labels.iter().map(String::as_str).collect(),
297        };
298        let json = serde_json::to_vec(&payload)
299            .map_err(|_| TrackerError::Transient { retry_after: None })?;
300        let uri = format!("{}/repos/{owner}/{repo}/issues", self.base);
301        let request = Self::request(cred, http::Method::POST, uri)
302            .header(CONTENT_TYPE, "application/json")
303            .body(Bytes::from(json))
304            .map_err(|_| TrackerError::Transient { retry_after: None })?;
305        let text = self.send_checked(request).await?;
306        let created: IssueRef = serde_json::from_str(&text).map_err(|_| {
307            // A 2xx we cannot read is not a create we can trust: report it
308            // transient, and let the next attempt's dedupe check settle it.
309            TrackerError::Transient { retry_after: None }
310        })?;
311        let filed = created
312            .filed()
313            .ok_or(TrackerError::Transient { retry_after: None })?;
314        tracing::info!(
315            provider = "github-issues",
316            outcome = "created",
317            idempotency = %draft.idempotency_key,
318            issue = %filed.external_id,
319            "tracker outcome"
320        );
321        Ok(filed)
322    }
323
324    /// GitHub has a ticket to read back, so this is a real read: GET the
325    /// issue and map what it says. `state_reason` distinguishes a
326    /// completed close from a "not planned" one — both are terminal, so
327    /// both are `Closed`; an open issue with an assignee is the one signal
328    /// GitHub gives that somebody picked it up.
329    async fn status(
330        &self,
331        dest: &Destination,
332        cred: &Credential,
333        external_id: &str,
334    ) -> Result<TicketStatus, TrackerError> {
335        let (owner, repo) = repo_of(dest)?;
336        let uri = format!("{}/repos/{owner}/{repo}/issues/{external_id}", self.base);
337        let request = Self::request(cred, http::Method::GET, uri)
338            .body(Bytes::new())
339            .map_err(|_| TrackerError::Transient { retry_after: None })?;
340        let text = self.send_checked(request).await?;
341        let issue: IssueState = serde_json::from_str(&text)
342            .map_err(|_| TrackerError::Transient { retry_after: None })?;
343        let state = match issue.state.as_str() {
344            "closed" => TicketState::Closed,
345            "open" if issue.assignee.is_some() => TicketState::InProgress,
346            "open" => TicketState::Open,
347            // The port names what it can; a state it cannot is said so
348            // rather than guessed into one of the four above.
349            _ => TicketState::Unknown,
350        };
351        Ok(TicketStatus {
352            external_id: external_id.to_owned(),
353            state,
354            url: issue.html_url,
355        })
356    }
357}
358
359/// The invisible-in-rendering HTML comment that ties an issue to an outbox
360/// idempotency key. It is the crate's own prefix (`cratefield-idem`), so a
361/// venture's prose can never collide with it by accident.
362/// The repository a destination names, or the rejection for one this
363/// adapter does not serve. `#[non_exhaustive]`: anything a later core adds
364/// is refused by name rather than silently doing nothing.
365fn repo_of(dest: &Destination) -> Result<(&str, &str), TrackerError> {
366    match dest {
367        Destination::GitHub { owner, repo } => Ok((owner.as_str(), repo.as_str())),
368        other => Err(TrackerError::unsupported_destination(other)),
369    }
370}
371
372/// The slice of an issue `status` reads. Separate from the create
373/// response: this asks a different question and must not drift with it.
374#[derive(serde::Deserialize)]
375struct IssueState {
376    state: String,
377    #[serde(default)]
378    assignee: Option<serde_json::Value>,
379    #[serde(default)]
380    html_url: Option<String>,
381}
382
383fn idem_marker(idempotency_key: &str) -> String {
384    format!("<!-- cratefield-idem: {idempotency_key} -->")
385}
386
387/// The issue fields this adapter reads, common to the search, list and
388/// create responses.
389#[derive(serde::Deserialize)]
390struct IssueRef {
391    /// The issue number, what the REST API addresses a ticket by.
392    #[serde(default)]
393    number: Option<i64>,
394    #[serde(default)]
395    body: Option<String>,
396    #[serde(default)]
397    html_url: Option<String>,
398    /// Set on pull requests, which the list endpoint returns among the
399    /// "issues"; a PR must never satisfy a marker match.
400    #[serde(default)]
401    pull_request: Option<serde_json::Value>,
402}
403
404impl IssueRef {
405    /// Whether this issue really is the one the marker names. The marker is
406    /// matched whole: search's token and substring matching is exactly why
407    /// the verify exists.
408    fn carries(&self, marker: &str) -> bool {
409        self.pull_request.is_none()
410            && self
411                .body
412                .as_deref()
413                .is_some_and(|body| body.contains(marker))
414    }
415
416    /// The [`Filed`] this wire shape reports, or `None` when GitHub answered
417    /// without a number — which no successful lookup or create does.
418    ///
419    /// `Filed` carries no `deduplicated` flag: whether this was a create or
420    /// a dedupe hit is said in the outcome log beside the call, and a
421    /// caller holding the record cannot act on the difference anyway —
422    /// both mean "this draft is filed, here". An issue GitHub returned
423    /// without an `html_url` is one it also returned without a number, so
424    /// the empty string here is unreachable rather than a silent gap.
425    fn filed(&self) -> Option<Filed> {
426        let external_id = self.number?.to_string();
427        Some(Filed {
428            external_id,
429            url: self.html_url.clone().unwrap_or_default(),
430        })
431    }
432}
433
434#[derive(serde::Deserialize)]
435struct SearchResponse {
436    #[serde(default)]
437    items: Vec<IssueRef>,
438}
439
440#[derive(serde::Deserialize)]
441struct ErrorResponse {
442    #[serde(default)]
443    message: String,
444}
445
446#[derive(serde::Serialize)]
447struct NewIssue<'a> {
448    title: &'a str,
449    body: &'a str,
450    #[serde(skip_serializing_if = "Vec::is_empty")]
451    labels: Vec<&'a str>,
452}
453
454/// Percent-encodes a value for a query parameter. The allowlist is RFC 3986's
455/// unreserved set, so the quoted marker's spaces, `<`/`>` and `:` all come
456/// out encoded instead of terminating the parameter early. (No `url` crate:
457/// one parameter is not worth a dependency.)
458fn percent_encode(value: &str) -> String {
459    let mut encoded = String::with_capacity(value.len());
460    for byte in value.bytes() {
461        match byte {
462            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
463                encoded.push(byte as char);
464            }
465            other => {
466                // Writing to a String cannot fail.
467                let _ = write!(encoded, "%{other:02X}");
468            }
469        }
470    }
471    encoded
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn the_marker_is_an_html_comment_naming_the_crate() {
480        assert_eq!(
481            idem_marker("idem-123"),
482            "<!-- cratefield-idem: idem-123 -->"
483        );
484    }
485
486    #[test]
487    fn a_search_query_is_percent_encoded_parameter_safe() {
488        // Space, `<`, `>`, `!`, `:` and `/` must all survive as one `q=`
489        // value, none of them terminating the parameter early.
490        let encoded =
491            percent_encode("\"<!-- cratefield-idem: k-1 -->\" repo:acme/widgets is:issue");
492        assert_eq!(
493            encoded,
494            "%22%3C%21--%20cratefield-idem%3A%20k-1%20--%3E%22%20repo%3Aacme%2Fwidgets%20is%3Aissue"
495        );
496    }
497
498    #[test]
499    fn a_pull_request_or_a_markerless_body_is_never_a_match() {
500        let marker = idem_marker("k-1");
501        let pr: IssueRef = serde_json::from_value(serde_json::json!({
502            "number": 9, "body": marker, "html_url": "https://github.test/acme/widgets/pull/9",
503            "pull_request": { "url": "https://github.test/acme/widgets/pull/9" },
504        }))
505        .expect("parses");
506        assert!(!pr.carries(&marker), "a pull request is not an issue");
507
508        let near_miss: IssueRef = serde_json::from_value(
509            serde_json::json!({ "number": 8, "body": "<!-- cratefield-idem: k-12 -->" }),
510        )
511        .expect("parses");
512        assert!(
513            !near_miss.carries(&marker),
514            "a substring of the marker is not the marker"
515        );
516
517        let verified: IssueRef = serde_json::from_value(
518            serde_json::json!({ "number": 7, "body": format!("hi\n\n{marker}") }),
519        )
520        .expect("parses");
521        assert!(verified.carries(&marker));
522        let filed = verified.filed().expect("a number");
523        assert_eq!(filed.external_id, "7");
524    }
525
526    #[test]
527    /// Stronger than redaction: there is no credential in the struct to
528    /// redact. The token arrives per call (#453), so a `Debug` print — and
529    /// anything else that reaches for the adapter's fields — cannot leak
530    /// one however the struct grows.
531    fn debug_of_the_adapter_holds_no_credential() {
532        let adapter = GitHubIssues::new(Arc::new(NoHttp), Arc::new(cratefield_core::SystemClock));
533        let printed = format!("{adapter:?}");
534        assert!(printed.contains("GitHubIssues"), "{printed}");
535        assert!(
536            !printed.contains("ghp_"),
537            "no token shape may appear: {printed}"
538        );
539    }
540
541    /// The transport is never reached by the `Debug` test above.
542    struct NoHttp;
543
544    #[async_trait]
545    impl HttpClient for NoHttp {
546        async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
547            Err(HttpError::Transport("not used".to_owned()))
548        }
549    }
550}