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}