use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::collect::errors::{CollectError, Result};
use crate::collect::github::client::GitHubClient;
use crate::collect::github::types::GitHubIssue;
#[derive(Debug, Deserialize)]
struct IssueSearchPage {
#[serde(default)]
items: Vec<GitHubIssue>,
#[serde(default)]
total_count: u64,
}
#[derive(Debug, Serialize)]
struct CreateIssueBody<'a> {
title: &'a str,
body: &'a str,
labels: &'a [String],
}
#[derive(Debug, Serialize)]
struct CreateCommentBody<'a> {
body: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IssueUpsert {
pub number: u64,
pub html_url: String,
pub created: bool,
}
pub fn issue_search_query(owner: &str, repo: &str, label: &str, marker: &str) -> String {
format!("repo:{owner}/{repo} label:{label} in:title {marker} type:issue")
}
pub fn thread_marker_anchor(marker: &str) -> String {
format!("<{marker}>")
}
pub fn find_thread_by_marker<'a>(
items: &'a [GitHubIssue],
marker: &str,
) -> Option<&'a GitHubIssue> {
let anchor = thread_marker_anchor(marker);
items.iter().find(|i| i.title.contains(&anchor))
}
impl GitHubClient {
pub async fn search_issues(
&self,
query: &str,
per_page: u32,
page: u32,
) -> Result<(Vec<GitHubIssue>, u64)> {
let per_page = per_page.to_string();
let page = page.to_string();
let url = reqwest::Url::parse_with_params(
&format!("{}/search/issues", self.api_base()),
&[
("q", query),
("per_page", per_page.as_str()),
("page", page.as_str()),
],
)
.map_err(|e| CollectError::Config(format!("cannot build GitHub search URL: {e}")))?;
debug!(url = %url, "GET (issue search)");
let resp = self.http_client().get(url.clone()).send().await?;
let body: IssueSearchPage = read_json(resp, url.as_str()).await?;
Ok((body.items, body.total_count))
}
pub async fn create_issue(
&self,
owner: &str,
repo: &str,
title: &str,
body: &str,
labels: &[String],
) -> Result<GitHubIssue> {
let url = format!("{}/repos/{owner}/{repo}/issues", self.api_base());
debug!(url = %url, title, "POST (create issue)");
let resp = self
.http_client()
.post(&url)
.json(&CreateIssueBody {
title,
body,
labels,
})
.send()
.await?;
read_json(resp, &url).await
}
pub async fn create_issue_comment(
&self,
owner: &str,
repo: &str,
number: u64,
body: &str,
) -> Result<()> {
let url = format!(
"{}/repos/{owner}/{repo}/issues/{number}/comments",
self.api_base()
);
debug!(url = %url, "POST (issue comment)");
let resp = self
.http_client()
.post(&url)
.json(&CreateCommentBody { body })
.send()
.await?;
let status = resp.status();
if !status.is_success() {
return Err(api_error(status.as_u16(), &url, resp.text().await.ok()));
}
Ok(())
}
pub async fn upsert_issue_thread(
&self,
owner: &str,
repo: &str,
label: &str,
title: &str,
marker: &str,
body: &str,
) -> Result<IssueUpsert> {
let anchor = thread_marker_anchor(marker);
if !title.contains(&anchor) {
return Err(CollectError::Config(format!(
"issue title '{title}' does not contain '{anchor}'; the thread it \
opens would be invisible to the next run, which would open another"
)));
}
let query = issue_search_query(owner, repo, label, marker);
let mut scanned = 0usize;
let mut total = 0u64;
for page in 1..=SEARCH_PAGE_BUDGET {
let (candidates, reported) = self.search_issues(&query, SEARCH_PAGE_SIZE, page).await?;
total = reported;
scanned += candidates.len();
if let Some(existing) = find_thread_by_marker(&candidates, marker) {
self.create_issue_comment(owner, repo, existing.number, body)
.await?;
info!(
number = existing.number,
url = %existing.html_url,
"appended profile to the existing issue thread"
);
return Ok(IssueUpsert {
number: existing.number,
html_url: existing.html_url.clone(),
created: false,
});
}
if (candidates.len() as u32) < SEARCH_PAGE_SIZE {
break;
}
}
if (scanned as u64) < total {
return Err(CollectError::GithubSearchInconclusive {
query,
scanned,
total,
});
}
let created = self
.create_issue(owner, repo, title, body, &[label.to_string()])
.await?;
info!(
number = created.number,
url = %created.html_url,
"opened a new issue thread for this contributor"
);
Ok(IssueUpsert {
number: created.number,
html_url: created.html_url,
created: true,
})
}
}
const SEARCH_PAGE_SIZE: u32 = 100;
const SEARCH_PAGE_BUDGET: u32 = 10;
const MAX_ERROR_BODY: usize = 400;
async fn read_json<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
endpoint: &str,
) -> Result<T> {
let status = resp.status();
if !status.is_success() {
return Err(api_error(status.as_u16(), endpoint, resp.text().await.ok()));
}
Ok(resp.json().await?)
}
fn api_error(status: u16, endpoint: &str, body: Option<String>) -> CollectError {
let mut message = body.unwrap_or_else(|| "(no body)".to_string());
if message.len() > MAX_ERROR_BODY {
message.truncate(MAX_ERROR_BODY);
message.push('…');
}
CollectError::GithubApi {
status,
endpoint: endpoint.to_string(),
message,
}
}
#[cfg(test)]
#[path = "issue_writer_tests.rs"]
mod tests;