#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{
Clock, Credential, Destination, Filed, HttpClient, HttpError, TicketDraft, TicketState,
TicketStatus, Tracker, TrackerError, retry_after,
};
use futures_util::lock::Mutex as AsyncMutex;
use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use http::{Request, StatusCode};
use std::fmt::Write as _;
use std::sync::Arc;
const DEFAULT_BASE: &str = "https://api.github.com";
const API_VERSION: &str = "2022-11-28";
const CLIENT_USER_AGENT: &str = "cratefield-adapter-github-issues";
const RECENT_ISSUES_PAGE: &str = "state=all&per_page=100";
pub struct GitHubIssues {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
base: String,
file_gate: AsyncMutex<()>,
}
impl std::fmt::Debug for GitHubIssues {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHubIssues")
.field("base", &self.base)
.finish_non_exhaustive()
}
}
impl GitHubIssues {
pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
Self {
http,
clock,
base: DEFAULT_BASE.to_owned(),
file_gate: AsyncMutex::new(()),
}
}
#[must_use]
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = base.into();
self
}
fn request(cred: &Credential, method: http::Method, uri: String) -> http::request::Builder {
Request::builder()
.method(method)
.uri(uri)
.header(AUTHORIZATION, format!("Bearer {}", cred.expose()))
.header(ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", API_VERSION)
.header(USER_AGENT, CLIENT_USER_AGENT)
}
async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
let response = self.http.send(request).await.map_err(|err: HttpError| {
match err {
HttpError::BlockedDestination(detail) => {
TrackerError::Rejected(format!("github issues destination refused: {detail}"))
}
other => {
tracing::warn!(
provider = "github-issues",
outcome = "failed",
error = %other,
"tracker outcome"
);
TrackerError::Transient { retry_after: None }
}
}
})?;
let status = response.status();
let text = String::from_utf8_lossy(response.body()).to_string();
if !status.is_success() {
let delay = retry_after(response.headers(), self.clock.as_ref());
let error = Self::map_status(status, &text, delay);
tracing::warn!(
provider = "github-issues",
code = status.as_u16(),
outcome = "failed",
detail = %text,
error = %error,
"tracker outcome"
);
return Err(error);
}
Ok(text)
}
fn map_status(
status: StatusCode,
body: &str,
retry_after: Option<std::time::Duration>,
) -> TrackerError {
let detail = match serde_json::from_str::<ErrorResponse>(body) {
Ok(parsed) if !parsed.message.is_empty() => parsed.message,
_ => body.to_string(),
};
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
StatusCode::NOT_FOUND | StatusCode::UNPROCESSABLE_ENTITY => {
TrackerError::Rejected(format!("github issues {status}: {detail}"))
}
status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
let _ = (status, detail);
TrackerError::Transient { retry_after }
}
status if status.is_client_error() => {
TrackerError::Rejected(format!("github issues {status}: {detail}"))
}
_ => TrackerError::Transient { retry_after },
}
}
async fn find_existing(
&self,
cred: &Credential,
owner: &str,
repo: &str,
marker: &str,
) -> Result<Option<Filed>, TrackerError> {
if let Some(existing) = self.search_existing(cred, owner, repo, marker).await? {
return Ok(Some(existing));
}
self.recent_existing(cred, owner, repo, marker).await
}
async fn search_existing(
&self,
cred: &Credential,
owner: &str,
repo: &str,
marker: &str,
) -> Result<Option<Filed>, TrackerError> {
let query = format!("\"{marker}\" repo:{owner}/{repo} is:issue");
let uri = format!("{}/search/issues?q={}", self.base, percent_encode(&query));
let request = Self::request(cred, http::Method::GET, uri)
.body(Bytes::new())
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let text = self.send_checked(request).await?;
let parsed: SearchResponse = serde_json::from_str(&text)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
Ok(parsed
.items
.iter()
.find(|issue| issue.carries(marker))
.and_then(IssueRef::filed))
}
async fn recent_existing(
&self,
cred: &Credential,
owner: &str,
repo: &str,
marker: &str,
) -> Result<Option<Filed>, TrackerError> {
let uri = format!(
"{}/repos/{owner}/{repo}/issues?{RECENT_ISSUES_PAGE}",
self.base
);
let request = Self::request(cred, http::Method::GET, uri)
.body(Bytes::new())
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let text = self.send_checked(request).await?;
let parsed: Vec<IssueRef> = serde_json::from_str(&text)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
Ok(parsed
.iter()
.find(|issue| issue.carries(marker))
.and_then(IssueRef::filed))
}
}
#[async_trait]
impl Tracker for GitHubIssues {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
let (owner, repo) = repo_of(dest)?;
let marker = idem_marker(&draft.idempotency_key);
let _single_flight = self.file_gate.lock().await;
if let Some(existing) = self.find_existing(cred, owner, repo, &marker).await? {
tracing::info!(
provider = "github-issues",
outcome = "deduplicated",
idempotency = %draft.idempotency_key,
issue = %existing.external_id,
"tracker outcome"
);
return Ok(existing);
}
let body = format!("{}\n\n{marker}", draft.body_markdown);
let payload = NewIssue {
title: &draft.title,
body: &body,
labels: draft.labels.iter().map(String::as_str).collect(),
};
let json = serde_json::to_vec(&payload)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let uri = format!("{}/repos/{owner}/{repo}/issues", self.base);
let request = Self::request(cred, http::Method::POST, uri)
.header(CONTENT_TYPE, "application/json")
.body(Bytes::from(json))
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let text = self.send_checked(request).await?;
let created: IssueRef = serde_json::from_str(&text).map_err(|_| {
TrackerError::Transient { retry_after: None }
})?;
let filed = created
.filed()
.ok_or(TrackerError::Transient { retry_after: None })?;
tracing::info!(
provider = "github-issues",
outcome = "created",
idempotency = %draft.idempotency_key,
issue = %filed.external_id,
"tracker outcome"
);
Ok(filed)
}
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
let (owner, repo) = repo_of(dest)?;
let uri = format!("{}/repos/{owner}/{repo}/issues/{external_id}", self.base);
let request = Self::request(cred, http::Method::GET, uri)
.body(Bytes::new())
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let text = self.send_checked(request).await?;
let issue: IssueState = serde_json::from_str(&text)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
let state = match issue.state.as_str() {
"closed" => TicketState::Closed,
"open" if issue.assignee.is_some() => TicketState::InProgress,
"open" => TicketState::Open,
_ => TicketState::Unknown,
};
Ok(TicketStatus {
external_id: external_id.to_owned(),
state,
url: issue.html_url,
})
}
}
fn repo_of(dest: &Destination) -> Result<(&str, &str), TrackerError> {
match dest {
Destination::GitHub { owner, repo } => Ok((owner.as_str(), repo.as_str())),
other => Err(TrackerError::unsupported_destination(other)),
}
}
#[derive(serde::Deserialize)]
struct IssueState {
state: String,
#[serde(default)]
assignee: Option<serde_json::Value>,
#[serde(default)]
html_url: Option<String>,
}
fn idem_marker(idempotency_key: &str) -> String {
format!("<!-- cratefield-idem: {idempotency_key} -->")
}
#[derive(serde::Deserialize)]
struct IssueRef {
#[serde(default)]
number: Option<i64>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
html_url: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
impl IssueRef {
fn carries(&self, marker: &str) -> bool {
self.pull_request.is_none()
&& self
.body
.as_deref()
.is_some_and(|body| body.contains(marker))
}
fn filed(&self) -> Option<Filed> {
let external_id = self.number?.to_string();
Some(Filed {
external_id,
url: self.html_url.clone().unwrap_or_default(),
})
}
}
#[derive(serde::Deserialize)]
struct SearchResponse {
#[serde(default)]
items: Vec<IssueRef>,
}
#[derive(serde::Deserialize)]
struct ErrorResponse {
#[serde(default)]
message: String,
}
#[derive(serde::Serialize)]
struct NewIssue<'a> {
title: &'a str,
body: &'a str,
#[serde(skip_serializing_if = "Vec::is_empty")]
labels: Vec<&'a str>,
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
other => {
let _ = write!(encoded, "%{other:02X}");
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_marker_is_an_html_comment_naming_the_crate() {
assert_eq!(
idem_marker("idem-123"),
"<!-- cratefield-idem: idem-123 -->"
);
}
#[test]
fn a_search_query_is_percent_encoded_parameter_safe() {
let encoded =
percent_encode("\"<!-- cratefield-idem: k-1 -->\" repo:acme/widgets is:issue");
assert_eq!(
encoded,
"%22%3C%21--%20cratefield-idem%3A%20k-1%20--%3E%22%20repo%3Aacme%2Fwidgets%20is%3Aissue"
);
}
#[test]
fn a_pull_request_or_a_markerless_body_is_never_a_match() {
let marker = idem_marker("k-1");
let pr: IssueRef = serde_json::from_value(serde_json::json!({
"number": 9, "body": marker, "html_url": "https://github.test/acme/widgets/pull/9",
"pull_request": { "url": "https://github.test/acme/widgets/pull/9" },
}))
.expect("parses");
assert!(!pr.carries(&marker), "a pull request is not an issue");
let near_miss: IssueRef = serde_json::from_value(
serde_json::json!({ "number": 8, "body": "<!-- cratefield-idem: k-12 -->" }),
)
.expect("parses");
assert!(
!near_miss.carries(&marker),
"a substring of the marker is not the marker"
);
let verified: IssueRef = serde_json::from_value(
serde_json::json!({ "number": 7, "body": format!("hi\n\n{marker}") }),
)
.expect("parses");
assert!(verified.carries(&marker));
let filed = verified.filed().expect("a number");
assert_eq!(filed.external_id, "7");
}
#[test]
fn debug_of_the_adapter_holds_no_credential() {
let adapter = GitHubIssues::new(Arc::new(NoHttp), Arc::new(cratefield_core::SystemClock));
let printed = format!("{adapter:?}");
assert!(printed.contains("GitHubIssues"), "{printed}");
assert!(
!printed.contains("ghp_"),
"no token shape may appear: {printed}"
);
}
struct NoHttp;
#[async_trait]
impl HttpClient for NoHttp {
async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
Err(HttpError::Transport("not used".to_owned()))
}
}
}