use async_trait::async_trait;
use serde::Deserialize;
use super::{
ContextSection, ContextSnippet, ContextSource, ContextSourceError, RetrievalMode,
ReviewSubject, SNIPPET_BODY_CHARS, TransportErr, truncate_on_char_boundary,
};
use crate::config::ReviewConfig;
use crate::integrations::github::{AuthStrategy, GithubClient, RunMode};
const SOURCE_NAME: &str = "github_issues";
const MAX_RESULTS: u32 = 5;
const MAX_QUERY_IDENTIFIERS: usize = 4;
const GITHUB_QUERY_MAX_CHARS: usize = 256;
fn cap_query(q: &str) -> &str {
if q.chars().count() <= GITHUB_QUERY_MAX_CHARS {
return q;
}
let hard_cut_byte = q
.char_indices()
.nth(GITHUB_QUERY_MAX_CHARS)
.map(|(i, _)| i)
.unwrap_or(q.len());
let candidate = &q[..hard_cut_byte];
let capped = match candidate.rfind(|c: char| c.is_whitespace()) {
Some(ws_byte) if ws_byte > 0 => &q[..ws_byte],
_ => candidate, };
tracing::debug!(
source = SOURCE_NAME,
original_chars = q.chars().count(),
capped_chars = capped.chars().count(),
limit = GITHUB_QUERY_MAX_CHARS,
"truncated GitHub issue-search query to stay within the Search API limit"
);
capped
}
#[async_trait]
pub trait IssueTokenResolver: Send + Sync {
async fn resolve(&self, owner: &str) -> Result<String, ContextSourceError>;
}
pub struct DualModeTokenResolver {
run_mode: RunMode,
config: ReviewConfig,
}
impl DualModeTokenResolver {
pub fn new(run_mode: RunMode, config: ReviewConfig) -> Self {
Self { run_mode, config }
}
}
#[async_trait]
impl IssueTokenResolver for DualModeTokenResolver {
async fn resolve(&self, owner: &str) -> Result<String, ContextSourceError> {
let client = GithubClient::new().map_err(|e| ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: format!("failed to build HTTP client: {e}"),
})?;
AuthStrategy::select(self.run_mode, None)
.resolve_token(&client, &self.config, owner)
.await
.map_err(|e| ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: format!("GitHub token unavailable: {e}"),
})
}
}
#[async_trait]
pub trait IssueSearchTransport: Send + Sync {
async fn search(
&self,
token: &str,
query: &str,
per_page: u32,
) -> Result<String, ContextSourceError>;
}
pub struct ReqwestIssueSearch {
http: reqwest::Client,
}
impl ReqwestIssueSearch {
pub fn new() -> Result<Self, ContextSourceError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("failed to build HTTP client: {e}")),
})?;
Ok(Self { http })
}
}
impl Default for ReqwestIssueSearch {
fn default() -> Self {
Self::new().expect("reqwest::Client::build failed — TLS backend unavailable")
}
}
#[async_trait]
impl IssueSearchTransport for ReqwestIssueSearch {
async fn search(
&self,
token: &str,
query: &str,
per_page: u32,
) -> Result<String, ContextSourceError> {
let url = "https://api.github.com/search/issues";
let resp = self
.http
.get(url)
.query(&[("q", query), ("per_page", &per_page.to_string())])
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "trusty-review")
.send()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("GET {url}: {e}")),
})?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr(format!("read body of {url}: {e}")),
})?;
if !status.is_success() {
return Err(ContextSourceError::Api {
src: SOURCE_NAME,
status: status.as_u16(),
body: text,
});
}
Ok(text)
}
}
struct DisabledTokenResolver;
#[async_trait]
impl IssueTokenResolver for DisabledTokenResolver {
async fn resolve(&self, _owner: &str) -> Result<String, ContextSourceError> {
Err(ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: "HTTP transport unavailable (TLS init failed)".to_string(),
})
}
}
struct DisabledIssueSearch;
#[async_trait]
impl IssueSearchTransport for DisabledIssueSearch {
async fn search(
&self,
_token: &str,
_query: &str,
_per_page: u32,
) -> Result<String, ContextSourceError> {
Err(ContextSourceError::Transport {
src: SOURCE_NAME,
err: TransportErr("HTTP transport unavailable (TLS init failed)".to_string()),
})
}
}
#[derive(Debug, Deserialize)]
struct IssueSearchResponse {
#[serde(default)]
items: Vec<IssueItem>,
}
#[derive(Debug, Deserialize)]
struct IssueItem {
number: u64,
#[serde(default)]
title: String,
#[serde(default)]
state: String,
#[serde(default)]
html_url: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
pub struct GithubIssuesSource {
enabled: bool,
mode: RetrievalMode,
token: Box<dyn IssueTokenResolver>,
transport: Box<dyn IssueSearchTransport>,
}
impl GithubIssuesSource {
pub fn from_config(cfg: &super::SourceConfig, run_mode: RunMode, config: ReviewConfig) -> Self {
let transport = match ReqwestIssueSearch::new() {
Ok(t) => Box::new(t) as Box<dyn IssueSearchTransport>,
Err(e) => {
tracing::error!(
"github_issues: failed to build HTTP transport (source disabled): {e}"
);
return Self {
enabled: false,
mode: cfg.mode,
token: Box::new(DisabledTokenResolver),
transport: Box::new(DisabledIssueSearch),
};
}
};
let enabled = cfg.effective_enabled(true);
Self {
enabled,
mode: cfg.mode,
token: Box::new(DualModeTokenResolver::new(run_mode, config)),
transport,
}
}
pub fn new(
enabled: bool,
mode: RetrievalMode,
token: Box<dyn IssueTokenResolver>,
transport: Box<dyn IssueSearchTransport>,
) -> Self {
Self {
enabled,
mode,
token,
transport,
}
}
fn build_query(subject: &ReviewSubject) -> Option<String> {
if subject.owner.is_empty() || subject.repo.is_empty() {
return None;
}
let keywords = subject.keyword_query(MAX_QUERY_IDENTIFIERS);
let keywords = keywords.trim();
if keywords.is_empty() {
return None;
}
let full = format!(
"repo:{}/{} is:issue {keywords}",
subject.owner, subject.repo
);
Some(cap_query(&full).to_string())
}
fn parse_section(body: &str) -> Result<ContextSection, ContextSourceError> {
let resp: IssueSearchResponse =
serde_json::from_str(body).map_err(|e| ContextSourceError::Parse {
src: SOURCE_NAME,
detail: e.to_string(),
})?;
let snippets = resp
.items
.into_iter()
.filter(|i| i.pull_request.is_none())
.map(|i| {
let title = if i.title.is_empty() {
format!("#{}", i.number)
} else {
format!("#{} — {}", i.number, i.title)
};
let body_excerpt = i.body.as_deref().map(str::trim).and_then(|b| {
(!b.is_empty())
.then(|| truncate_on_char_boundary(b, SNIPPET_BODY_CHARS).to_string())
});
ContextSnippet {
title,
subtitle: (!i.state.is_empty()).then(|| i.state.clone()),
body: body_excerpt,
link: (!i.html_url.is_empty()).then(|| i.html_url.clone()),
}
})
.collect();
Ok(ContextSection {
heading: "Related GitHub issues".to_string(),
snippets,
})
}
}
#[async_trait]
impl ContextSource for GithubIssuesSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn mode(&self) -> RetrievalMode {
self.mode
}
async fn gather(&self, subject: &ReviewSubject) -> Result<ContextSection, ContextSourceError> {
if self.mode == RetrievalMode::Semantic {
return Err(ContextSourceError::SemanticNotImplemented { src: SOURCE_NAME });
}
let Some(query) = Self::build_query(subject) else {
return Ok(ContextSection {
heading: "Related GitHub issues".to_string(),
snippets: Vec::new(),
});
};
let token = self.token.resolve(&subject.owner).await?;
let body = self.transport.search(&token, &query, MAX_RESULTS).await?;
Self::parse_section(&body)
}
}
#[cfg(test)]
#[path = "github_issues_tests.rs"]
mod tests;