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}