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