use std::net::IpAddr;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::{Client, header};
use serde::de::DeserializeOwned;
use url::{Host, Url};
use travelagent_core::error::{Result, TrvError};
use travelagent_core::forge::{
ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType, ForgeWarnHandler,
MergeMethod, MergeableStatus, NewComment, NewReview, Permissions, PrId, PrListFilter,
PrListItem, PrMetadata, PrState, ReactionContent, ReactionTarget, RemoteComment, ReviewThread,
ReviewVerdict, User,
};
use travelagent_core::model::{DiffFile, FileStatus, LineSide};
use travelagent_core::vcs::CommitInfo;
use travelagent_core::vcs::diff_parser::parse_patch_hunks;
use crate::auth;
use crate::types::{
GhCommit, GhCommitDetail2, GhFile, GhIssueComment, GhPullRequest, GhPullRequestListItem,
GhRepo, GhRepoPermissions, GhReviewComment, GhUser,
};
const MAX_ERROR_BODY_BYTES: usize = 2 * 1024;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub struct GitHubForge {
client: Client,
base_url: String,
warn_handler: Option<ForgeWarnHandler>,
}
impl GitHubForge {
pub fn new() -> Result<Self> {
Self::with_base_url("https://api.github.com")
}
fn validate_base_url(base_url: &str, allow_insecure: bool) -> Result<String> {
let parsed = Url::parse(base_url)
.map_err(|_| TrvError::AuthError("base_url is not a valid URL".to_string()))?;
if !allow_insecure && parsed.scheme() != "https" {
return Err(TrvError::AuthError(
"base_url must use https:// scheme".to_string(),
));
}
if parsed.scheme() != "https" && parsed.scheme() != "http" {
return Err(TrvError::AuthError(
"base_url must use http:// or https:// scheme".to_string(),
));
}
if !allow_insecure {
match parsed.host() {
Some(Host::Ipv4(ip)) => {
let ip = IpAddr::V4(ip);
if is_blocked_ip(&ip) {
return Err(TrvError::AuthError(
"base_url host resolves to a private, loopback, or link-local address"
.to_string(),
));
}
}
Some(Host::Ipv6(ip)) => {
let ip = IpAddr::V6(ip);
if is_blocked_ip(&ip) {
return Err(TrvError::AuthError(
"base_url host resolves to a private, loopback, or link-local address"
.to_string(),
));
}
}
Some(Host::Domain(_)) => {}
None => {
return Err(TrvError::AuthError(
"base_url must include a host".to_string(),
));
}
}
}
Ok(base_url.trim_end_matches('/').to_string())
}
pub fn with_base_url(base_url: &str) -> Result<Self> {
let token = auth::resolve_token()?;
Self::with_token(base_url, token)
}
pub fn with_token(base_url: &str, token: String) -> Result<Self> {
Self::with_token_validated(base_url, token, false)
}
pub fn with_token_insecure(base_url: &str, token: String) -> Result<Self> {
Self::with_token_validated(base_url, token, true)
}
fn with_token_validated(base_url: &str, token: String, allow_insecure: bool) -> Result<Self> {
let normalized_base = Self::validate_base_url(base_url, allow_insecure)?;
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
"X-GitHub-Api-Version",
header::HeaderValue::from_static("2022-11-28"),
);
headers.insert(
header::AUTHORIZATION,
format!("Bearer {token}").parse().map_err(|_| {
TrvError::AuthError(
"token contains invalid header bytes; check GITHUB_TOKEN".to_string(),
)
})?,
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("travelagent"),
);
let client = Client::builder()
.default_headers(headers)
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.map_err(|e| TrvError::ForgeApi(format!("Failed to create HTTP client: {e}")))?;
Ok(Self {
client,
base_url: normalized_base,
warn_handler: None,
})
}
pub fn with_warn_handler(mut self, handler: ForgeWarnHandler) -> Self {
self.warn_handler = Some(handler);
self
}
fn emit_warning(&self, msg: String) {
if let Some(ref handler) = self.warn_handler {
handler(msg);
} else {
eprintln!("{msg}");
}
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
let resp = Self::check_response(resp, &url).await?;
resp.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
}
async fn post<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.post(&url)
.json(body)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
let resp = Self::check_response(resp, &url).await?;
resp.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
}
async fn post_no_content(&self, path: &str, body: &serde_json::Value) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.post(&url)
.json(body)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
Self::check_response(resp, &url).await?;
Ok(())
}
async fn patch<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.patch(&url)
.json(body)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
let resp = Self::check_response(resp, &url).await?;
resp.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
}
async fn put_no_content(&self, path: &str, body: &serde_json::Value) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.put(&url)
.json(body)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
Self::check_response(resp, &url).await?;
Ok(())
}
async fn delete(&self, path: &str) -> Result<()> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.client
.delete(&url)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
Self::check_response(resp, &url).await?;
Ok(())
}
fn check_response_status(resp: &reqwest::Response, url: &str) -> Result<()> {
match resp.status().as_u16() {
200..=299 => Ok(()),
401 => Err(TrvError::AuthError(
"GitHub API authentication failed".into(),
)),
403 if resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
== Some("0") =>
{
Err(TrvError::RateLimited)
}
404 => Err(TrvError::NotFound(url.to_string())),
status => Err(TrvError::ForgeApi(format!("GitHub API {status} for {url}"))),
}
}
async fn check_response(resp: reqwest::Response, url: &str) -> Result<reqwest::Response> {
match resp.status().as_u16() {
200..=299 => Ok(resp),
401 => Err(TrvError::AuthError(
"GitHub API authentication failed".into(),
)),
403 if resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
== Some("0") =>
{
Err(TrvError::RateLimited)
}
404 => {
let body = read_error_body(resp).await;
let body = sanitize_error_body(&body);
Err(TrvError::NotFound(format!("{url}: {body}")))
}
status => {
let body = read_error_body(resp).await;
let body = sanitize_error_body(&body);
Err(TrvError::ForgeApi(format!(
"GitHub API {status} for {url}: {body}"
)))
}
}
}
async fn get_all_pages<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>> {
const MAX_PAGES: usize = 50;
let mut all: Vec<T> = Vec::new();
let mut page_count: usize = 0;
let mut url = if path.contains('?') {
format!("{}{}&per_page=100", self.base_url, path)
} else {
format!("{}{}?per_page=100", self.base_url, path)
};
loop {
if page_count >= MAX_PAGES {
self.emit_warning(format!(
"trv: warning: GitHub pagination hit MAX_PAGES={MAX_PAGES} limit for {path}; results may be truncated"
));
break;
}
page_count += 1;
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
Self::check_response_status(&resp, &url)?;
let next_url = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.and_then(parse_next_link);
let page: Vec<T> = resp
.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))?;
all.extend(page);
match next_url {
Some(next) => url = next,
None => break,
}
}
Ok(all)
}
async fn get_pages_up_to<T: DeserializeOwned>(
&self,
path: &str,
limit: usize,
) -> Result<Vec<T>> {
const MAX_PAGES: usize = 50;
let per_page = limit.clamp(1, 100);
let mut all: Vec<T> = Vec::new();
let mut page_count: usize = 0;
let mut url = if path.contains('?') {
format!("{}{}&per_page={}", self.base_url, path, per_page)
} else {
format!("{}{}?per_page={}", self.base_url, path, per_page)
};
loop {
if page_count >= MAX_PAGES {
self.emit_warning(format!(
"trv: warning: GitHub pagination hit MAX_PAGES={MAX_PAGES} limit for {path}; results may be truncated"
));
break;
}
page_count += 1;
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
Self::check_response_status(&resp, &url)?;
let next_url = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.and_then(parse_next_link);
let page: Vec<T> = resp
.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))?;
all.extend(page);
if all.len() >= limit {
all.truncate(limit);
break;
}
match next_url {
Some(next) => url = next,
None => break,
}
}
Ok(all)
}
fn graphql_url(&self) -> String {
if self.base_url.ends_with("/api/v3") {
format!("{}/graphql", self.base_url.trim_end_matches("/v3"))
} else {
format!("{}/graphql", self.base_url)
}
}
async fn graphql_with_vars(
&self,
query: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value> {
let url = self.graphql_url();
let resp = self
.client
.post(&url)
.json(&serde_json::json!({ "query": query, "variables": variables }))
.send()
.await
.map_err(|e| TrvError::ForgeApi(format!("GraphQL request failed: {e}")))?;
let resp = Self::check_response(resp, &url).await?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| TrvError::ForgeApi(format!("GraphQL parse error: {e}")))?;
if let Some(errors) = body.get("errors") {
return Err(TrvError::ForgeApi(format!("GraphQL errors: {errors}")));
}
Ok(body)
}
}
fn is_blocked_ip(ip: &IpAddr) -> bool {
if ip.is_loopback() || ip.is_unspecified() {
return true;
}
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
}
IpAddr::V6(v6) => {
(v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| v6
.to_ipv4_mapped()
.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
}
}
}
async fn read_error_body(resp: reqwest::Response) -> String {
match resp.text().await {
Ok(body) => body,
Err(e) => format!("<body read failed: {e}>"),
}
}
fn sanitize_error_body(body: &str) -> String {
let filtered: String = body
.lines()
.filter(|line| !line_looks_like_credential_header(line))
.collect::<Vec<_>>()
.join("\n");
if filtered.len() <= MAX_ERROR_BODY_BYTES {
filtered
} else {
let mut end = MAX_ERROR_BODY_BYTES;
while end > 0 && !filtered.is_char_boundary(end) {
end -= 1;
}
let mut out = filtered[..end].to_string();
out.push_str("... [truncated]");
out
}
}
fn line_looks_like_credential_header(line: &str) -> bool {
let colon = match line.find(':') {
Some(i) => i,
None => return false,
};
let name = line[..colon].trim().to_ascii_lowercase();
if name.is_empty() {
return false;
}
if name == "authorization" || name == "private-token" {
return true;
}
if let Some(rest) = name.strip_prefix("x-") {
return rest == "token" || rest.ends_with("-token");
}
false
}
fn parse_next_link(link_header: &str) -> Option<String> {
for part in link_header.split(',') {
let trimmed = part.trim();
if trimmed.ends_with("rel=\"next\"")
&& let Some(url) = trimmed.strip_suffix("; rel=\"next\"")
{
return Some(url.trim().trim_matches('<').trim_matches('>').to_string());
}
}
None
}
fn parse_datetime(s: &str) -> Result<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&chrono::Utc))
.map_err(|e| TrvError::ForgeApi(format!("Invalid datetime '{s}': {e}")))
}
fn default_list_prs_max() -> u32 {
30
}
fn cap_list_prs_max(m: u32) -> u32 {
m.clamp(1, 100)
}
fn convert_pr_list_item(
gh: GhPullRequestListItem,
current_user_login: Option<&str>,
) -> Result<PrListItem> {
let state = if gh.merged_at.is_some() {
PrState::Merged
} else if gh.state == "closed" {
PrState::Closed
} else {
PrState::Open
};
let has_review_requested_from_me = match current_user_login {
Some(me) => gh.requested_reviewers.iter().any(|u| u.login == me),
None => false,
};
let comment_count = {
let review = gh.review_comments.unwrap_or(0);
let issue = gh.comments.unwrap_or(0);
u32::try_from(review.saturating_add(issue)).unwrap_or(u32::MAX)
};
let assignees = gh.assignees.iter().map(|u| u.login.clone()).collect();
let reviewers = gh
.requested_reviewers
.iter()
.map(|u| u.login.clone())
.collect();
Ok(PrListItem {
number: gh.number,
title: gh.title,
author: gh.user.login,
state,
is_draft: gh.draft.unwrap_or(false),
base_branch: gh.base.ref_name,
head_branch: gh.head.ref_name,
updated_at: parse_datetime(&gh.updated_at)?,
comment_count,
has_review_requested_from_me,
assignees,
reviewers,
})
}
fn convert_pr(gh: GhPullRequest) -> Result<PrMetadata> {
let state = if gh.merged_at.is_some() {
PrState::Merged
} else if gh.state == "closed" {
PrState::Closed
} else {
PrState::Open
};
let mergeable = gh.mergeable_state.as_deref().map(|s| match s {
"clean" => MergeableStatus::Clean,
"unstable" => MergeableStatus::Unstable,
"behind" => MergeableStatus::Behind,
"blocked" => MergeableStatus::Blocked,
"dirty" => MergeableStatus::Dirty,
"draft" => MergeableStatus::Draft,
_ => MergeableStatus::Unknown,
});
Ok(PrMetadata {
title: gh.title,
body: gh.body.unwrap_or_default(),
author: gh.user.login,
state,
base_branch: gh.base.ref_name,
head_branch: gh.head.ref_name,
head_sha: gh.head.sha,
created_at: parse_datetime(&gh.created_at)?,
mergeable,
is_draft: gh.draft.unwrap_or(false),
})
}
fn convert_commit(gh: GhCommit) -> Result<CommitInfo> {
let short_id = if gh.sha.len() >= 7 {
gh.sha[..7].to_string()
} else {
gh.sha.clone()
};
let (summary, body) = match gh.commit.message.split_once('\n') {
Some((s, b)) => (s.to_string(), Some(b.trim().to_string())),
None => (gh.commit.message.clone(), None),
};
let author = gh.author.map(|u| u.login).unwrap_or(gh.commit.author.name);
let time = match gh.commit.author.date {
Some(d) => parse_datetime(&d)?,
None => chrono::Utc::now(),
};
Ok(CommitInfo {
id: gh.sha,
short_id,
branch_name: None,
summary,
body,
author,
time,
})
}
fn convert_review_comment(gh: GhReviewComment) -> Result<RemoteComment> {
let side = gh.side.as_deref().map(|s| match s {
"LEFT" => LineSide::Old,
_ => LineSide::New,
});
Ok(RemoteComment {
id: gh.id,
author: gh.user.login,
body: gh.body,
path: Some(gh.path),
line: gh.line,
side,
created_at: parse_datetime(&gh.created_at)?,
in_reply_to: gh.in_reply_to_id,
})
}
fn parse_gh_files(gh_files: Vec<GhFile>) -> Vec<DiffFile> {
gh_files.into_iter().map(parse_single_gh_file).collect()
}
fn parse_single_gh_file(gh: GhFile) -> DiffFile {
let status = match gh.status.as_str() {
"added" => FileStatus::Added,
"removed" => FileStatus::Deleted,
"renamed" => FileStatus::Renamed,
"copied" => FileStatus::Copied,
_ => FileStatus::Modified,
};
let old_path = match status {
FileStatus::Added => None,
FileStatus::Renamed | FileStatus::Copied => {
gh.previous_filename.map(std::path::PathBuf::from)
}
_ => Some(std::path::PathBuf::from(&gh.filename)),
};
let new_path = match status {
FileStatus::Deleted => None,
_ => Some(std::path::PathBuf::from(&gh.filename)),
};
let hunks = match &gh.patch {
Some(patch) if !patch.is_empty() => parse_patch_hunks(patch),
_ => Vec::new(),
};
let is_binary = gh.patch.is_none()
&& gh.additions == 0
&& gh.deletions == 0
&& status != FileStatus::Renamed
&& status != FileStatus::Copied;
let is_too_large = gh.patch.is_none() && (gh.additions > 0 || gh.deletions > 0);
DiffFile {
old_path,
new_path,
status,
hunks,
is_binary,
is_too_large,
is_commit_message: false,
}
}
#[async_trait]
impl ForgeRead for GitHubForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
async fn get_pr(&self, id: &PrId) -> Result<PrMetadata> {
let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
let gh: GhPullRequest = self.get(&path).await?;
convert_pr(gh)
}
async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>> {
let path = format!(
"/repos/{}/{}/pulls/{}/commits",
id.owner, id.repo, id.number
);
let gh: Vec<GhCommit> = self.get_all_pages(&path).await?;
gh.into_iter().map(convert_commit).collect()
}
async fn list_prs(
&self,
owner: &str,
repo: &str,
filter: &PrListFilter,
) -> Result<Vec<PrListItem>> {
let limit = cap_list_prs_max(filter.max.unwrap_or(default_list_prs_max())) as usize;
let state_param = match filter.state {
Some(PrState::Closed) => "closed",
Some(PrState::Merged) => "closed", _ => "open",
};
let path =
format!("/repos/{owner}/{repo}/pulls?state={state_param}&sort=updated&direction=desc");
let rows: Vec<GhPullRequestListItem> = self.get_pages_up_to(&path, limit).await?;
let me = self.current_user().await.ok().map(|u| u.login);
let mut out: Vec<PrListItem> = rows
.into_iter()
.map(|row| convert_pr_list_item(row, me.as_deref()))
.collect::<Result<_>>()?;
if let Some(ref who) = filter.author {
out.retain(|r| r.author.eq_ignore_ascii_case(who));
}
if let Some(ref who) = filter.assignee {
out.retain(|r| r.assignees.iter().any(|a| a.eq_ignore_ascii_case(who)));
}
if let Some(true) = filter.review_requested {
out.retain(|r| r.has_review_requested_from_me);
}
Ok(out)
}
async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>> {
let path = format!("/repos/{}/{}/pulls/{}/files", id.owner, id.repo, id.number);
let gh: Vec<GhFile> = self.get_all_pages(&path).await?;
Ok(parse_gh_files(gh))
}
async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>> {
if !commit_sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TrvError::ForgeApi(format!(
"Invalid commit SHA: '{commit_sha}'"
)));
}
let path = format!("/repos/{}/{}/commits/{}", id.owner, id.repo, commit_sha);
let gh: GhCommitDetail2 = self.get(&path).await?;
Ok(parse_gh_files(gh.files.unwrap_or_default()))
}
async fn current_user(&self) -> Result<User> {
let gh: GhUser = self.get("/user").await?;
Ok(User {
login: gh.login,
id: gh.id,
})
}
async fn check_permissions(&self, id: &PrId) -> Result<Permissions> {
let path = format!("/repos/{}/{}", id.owner, id.repo);
let gh: GhRepo = self.get(&path).await?;
let perms = gh.permissions.unwrap_or(GhRepoPermissions {
push: None,
maintain: None,
admin: None,
});
let can_push = perms.push.unwrap_or(false)
|| perms.maintain.unwrap_or(false)
|| perms.admin.unwrap_or(false);
let can_merge = perms.maintain.unwrap_or(false) || perms.admin.unwrap_or(false);
let mut allowed_merge_methods = Vec::new();
if gh.allow_merge_commit.unwrap_or(true) {
allowed_merge_methods.push(MergeMethod::Merge);
}
if gh.allow_squash_merge.unwrap_or(true) {
allowed_merge_methods.push(MergeMethod::Squash);
}
if gh.allow_rebase_merge.unwrap_or(true) {
allowed_merge_methods.push(MergeMethod::Rebase);
}
Ok(Permissions {
can_push,
can_merge,
allowed_merge_methods,
})
}
}
#[async_trait]
impl ForgeComments for GitHubForge {
async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>> {
let review_path = format!(
"/repos/{}/{}/pulls/{}/comments",
id.owner, id.repo, id.number
);
let review_comments: Vec<GhReviewComment> = self.get_all_pages(&review_path).await?;
let issue_path = format!(
"/repos/{}/{}/issues/{}/comments",
id.owner, id.repo, id.number
);
let issue_comments: Vec<GhIssueComment> = self.get_all_pages(&issue_path).await?;
let mut all: Vec<RemoteComment> = review_comments
.into_iter()
.map(convert_review_comment)
.collect::<Result<Vec<_>>>()?;
for ic in issue_comments {
all.push(RemoteComment {
id: ic.id,
author: ic.user.login,
body: ic.body.unwrap_or_default(),
path: None,
line: None,
side: None,
created_at: parse_datetime(&ic.created_at)?,
in_reply_to: None,
});
}
all.sort_by_key(|c| c.created_at);
Ok(all)
}
async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>> {
const MAX_PAGES: usize = 50;
let mut all_threads = Vec::new();
let mut cursor: Option<String> = None;
let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
id
isResolved
comments(first: 1) {
nodes { databaseId }
}
}
}
}
}
}";
for _ in 0..MAX_PAGES {
let variables = serde_json::json!({
"owner": id.owner,
"name": id.repo,
"number": id.number,
"after": cursor,
});
let body = self.graphql_with_vars(query, variables).await?;
let review_threads = body
.pointer("/data/repository/pullRequest/reviewThreads")
.cloned()
.unwrap_or_default();
let nodes = review_threads
.get("nodes")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for t in nodes {
let thread_id = t
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let is_resolved = t
.get("isResolved")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let root_comment_id = t
.pointer("/comments/nodes/0/databaseId")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
all_threads.push(ReviewThread {
id: thread_id,
is_resolved,
root_comment_id,
});
}
let has_next = review_threads
.pointer("/pageInfo/hasNextPage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if has_next {
cursor = review_threads
.pointer("/pageInfo/endCursor")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
} else {
break;
}
}
Ok(all_threads)
}
async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment> {
let commit_id = if let Some(sha) = &comment.commit_id {
sha.clone()
} else {
let pr_path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
let pr: GhPullRequest = self.get(&pr_path).await?;
pr.head.sha
};
let path = format!(
"/repos/{}/{}/pulls/{}/comments",
id.owner, id.repo, id.number
);
let side = match comment.side {
LineSide::Old => "LEFT",
LineSide::New => "RIGHT",
};
let mut body = serde_json::json!({
"path": comment.path,
"body": comment.body,
"line": comment.line,
"side": side,
"commit_id": commit_id,
});
if let Some(start) = comment.start_line {
let start_side = match comment.side {
LineSide::Old => "LEFT",
LineSide::New => "RIGHT",
};
body["start_line"] = serde_json::json!(start);
body["start_side"] = serde_json::json!(start_side);
}
let gh: GhReviewComment = self.post(&path, &body).await?;
convert_review_comment(gh)
}
async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment> {
let comment_id: u64 = thread_id.parse().map_err(|_| {
TrvError::ForgeApi(format!(
"Invalid comment ID for reply: '{thread_id}'. \
Pass the root comment's numeric ID, not the GraphQL thread node ID."
))
})?;
let path = format!(
"/repos/{}/{}/pulls/{}/comments",
id.owner, id.repo, id.number
);
let req_body = serde_json::json!({
"body": body,
"in_reply_to": comment_id,
});
let gh: GhReviewComment = self.post(&path, &req_body).await?;
convert_review_comment(gh)
}
async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment> {
let path = format!(
"/repos/{}/{}/pulls/comments/{}",
id.owner, id.repo, comment_id
);
let req_body = serde_json::json!({ "body": body });
let gh: GhReviewComment = self.patch(&path, &req_body).await?;
convert_review_comment(gh)
}
async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()> {
let path = format!(
"/repos/{}/{}/pulls/comments/{}",
id.owner, id.repo, comment_id
);
self.delete(&path).await
}
async fn resolve_thread(&self, thread_id: &str) -> Result<()> {
let query = r"mutation($threadId: ID!) {
resolveReviewThread(input: { threadId: $threadId }) {
thread { id isResolved }
}
}";
let variables = serde_json::json!({ "threadId": thread_id });
self.graphql_with_vars(query, variables).await?;
Ok(())
}
async fn unresolve_thread(&self, thread_id: &str) -> Result<()> {
let query = r"mutation($threadId: ID!) {
unresolveReviewThread(input: { threadId: $threadId }) {
thread { id isResolved }
}
}";
let variables = serde_json::json!({ "threadId": thread_id });
self.graphql_with_vars(query, variables).await?;
Ok(())
}
}
#[async_trait]
impl ForgeReview for GitHubForge {
async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()> {
let path = format!(
"/repos/{}/{}/pulls/{}/reviews",
id.owner, id.repo, id.number
);
let event = match review.verdict {
ReviewVerdict::Approve => "APPROVE",
ReviewVerdict::RequestChanges => "REQUEST_CHANGES",
ReviewVerdict::Comment => "COMMENT",
};
let comments: Vec<serde_json::Value> = review
.comments
.into_iter()
.map(|c| {
let side = match c.side {
LineSide::Old => "LEFT",
LineSide::New => "RIGHT",
};
let mut obj = serde_json::json!({
"path": c.path,
"body": c.body,
"line": c.line,
"side": side,
});
if let Some(start) = c.start_line {
obj["start_line"] = serde_json::json!(start);
obj["start_side"] = serde_json::json!(side);
}
obj
})
.collect();
let body = serde_json::json!({
"body": review.body,
"event": event,
"comments": comments,
});
self.post_no_content(&path, &body).await
}
}
#[async_trait]
impl ForgeMerge for GitHubForge {
async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()> {
let path = format!("/repos/{}/{}/pulls/{}/merge", id.owner, id.repo, id.number);
let merge_method = match method {
MergeMethod::Merge => "merge",
MergeMethod::Squash => "squash",
MergeMethod::Rebase => "rebase",
};
let body = serde_json::json!({ "merge_method": merge_method });
self.put_no_content(&path, &body).await
}
async fn close(&self, id: &PrId) -> Result<()> {
let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
let body = serde_json::json!({ "state": "closed" });
let _: serde_json::Value = self.patch(&path, &body).await?;
Ok(())
}
async fn reopen(&self, id: &PrId) -> Result<()> {
let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
let body = serde_json::json!({ "state": "open" });
let _: serde_json::Value = self.patch(&path, &body).await?;
Ok(())
}
}
#[async_trait]
impl ForgeReactions for GitHubForge {
async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()> {
match target {
ReactionTarget::IssueComment(_) => {
Err(TrvError::UnsupportedOperation(
"add_reaction for IssueComment requires repository context not available in current trait".into(),
))
}
ReactionTarget::ReviewComment(_) => {
Err(TrvError::UnsupportedOperation(
"add_reaction for ReviewComment requires repository context not available in current trait".into(),
))
}
ReactionTarget::Review(node_id) => {
let query = r"mutation($subjectId: ID!, $content: ReactionContent!) {
addReaction(input: { subjectId: $subjectId, content: $content }) {
reaction { content }
}
}";
let variables = serde_json::json!({
"subjectId": node_id,
"content": reaction_to_graphql(content),
});
self.graphql_with_vars(query, variables).await?;
Ok(())
}
}
}
async fn remove_reaction(
&self,
target: &ReactionTarget,
content: ReactionContent,
) -> Result<()> {
match target {
ReactionTarget::IssueComment(_) | ReactionTarget::ReviewComment(_) => {
Err(TrvError::UnsupportedOperation(
"remove_reaction requires repository context not available in current trait"
.into(),
))
}
ReactionTarget::Review(node_id) => {
let query = r"mutation($subjectId: ID!, $content: ReactionContent!) {
removeReaction(input: { subjectId: $subjectId, content: $content }) {
reaction { content }
}
}";
let variables = serde_json::json!({
"subjectId": node_id,
"content": reaction_to_graphql(content),
});
self.graphql_with_vars(query, variables).await?;
Ok(())
}
}
}
}
fn reaction_to_graphql(content: ReactionContent) -> &'static str {
match content {
ReactionContent::ThumbsUp => "THUMBS_UP",
ReactionContent::ThumbsDown => "THUMBS_DOWN",
ReactionContent::Laugh => "LAUGH",
ReactionContent::Hooray => "HOORAY",
ReactionContent::Confused => "CONFUSED",
ReactionContent::Heart => "HEART",
ReactionContent::Rocket => "ROCKET",
ReactionContent::Eyes => "EYES",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{GhCommitDetail, GhGitActor, GhRef};
#[test]
fn parse_next_link_with_next() {
let header = r#"<https://api.github.com/repos/o/r/pulls?page=2>; rel="next", <https://api.github.com/repos/o/r/pulls?page=5>; rel="last""#;
assert_eq!(
parse_next_link(header),
Some("https://api.github.com/repos/o/r/pulls?page=2".to_string())
);
}
#[test]
fn parse_next_link_without_next() {
let header = r#"<https://api.github.com/repos/o/r/pulls?page=5>; rel="last""#;
assert_eq!(parse_next_link(header), None);
}
#[test]
fn parse_next_link_empty() {
assert_eq!(parse_next_link(""), None);
}
#[test]
fn parse_hunk_header_basic() {
use travelagent_core::vcs::diff_parser::parse_hunk_header;
assert_eq!(parse_hunk_header("@@ -1,3 +1,4 @@"), Some((1, 3, 1, 4)));
}
#[test]
fn parse_hunk_header_with_context() {
use travelagent_core::vcs::diff_parser::parse_hunk_header;
assert_eq!(
parse_hunk_header("@@ -10,5 +20,8 @@ fn main()"),
Some((10, 5, 20, 8))
);
}
#[test]
fn parse_hunk_header_no_count() {
use travelagent_core::vcs::diff_parser::parse_hunk_header;
assert_eq!(parse_hunk_header("@@ -5 +10 @@"), Some((5, 1, 10, 1)));
}
#[test]
fn parse_patch_single_hunk() {
use travelagent_core::model::LineOrigin;
let patch = "@@ -1,3 +1,4 @@\n line1\n+added\n line2\n line3";
let hunks = parse_patch_hunks(patch);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].lines.len(), 4);
assert_eq!(hunks[0].lines[0].origin, LineOrigin::Context);
assert_eq!(hunks[0].lines[1].origin, LineOrigin::Addition);
assert_eq!(hunks[0].lines[1].content, "added");
}
#[test]
fn parse_patch_multiple_hunks() {
let patch = "@@ -1,2 +1,3 @@\n a\n+b\n c\n@@ -10,1 +11,1 @@\n-old\n+new";
let hunks = parse_patch_hunks(patch);
assert_eq!(hunks.len(), 2);
}
#[test]
fn parse_patch_line_numbers() {
let patch = "@@ -5,3 +5,4 @@\n ctx\n-del\n+add1\n+add2";
let hunks = parse_patch_hunks(patch);
let lines = &hunks[0].lines;
assert_eq!(lines[0].old_lineno, Some(5));
assert_eq!(lines[0].new_lineno, Some(5));
assert_eq!(lines[1].old_lineno, Some(6));
assert_eq!(lines[1].new_lineno, None);
assert_eq!(lines[2].old_lineno, None);
assert_eq!(lines[2].new_lineno, Some(6));
assert_eq!(lines[3].old_lineno, None);
assert_eq!(lines[3].new_lineno, Some(7));
}
#[test]
fn parse_single_gh_file_added() {
let gh = GhFile {
filename: "new.rs".into(),
status: "added".into(),
additions: 5,
deletions: 0,
patch: Some("@@ -0,0 +1,2 @@\n+line1\n+line2".into()),
previous_filename: None,
};
let df = parse_single_gh_file(gh);
assert_eq!(df.status, FileStatus::Added);
assert!(df.old_path.is_none());
assert_eq!(df.new_path, Some(std::path::PathBuf::from("new.rs")));
assert_eq!(df.hunks.len(), 1);
}
#[test]
fn parse_single_gh_file_renamed() {
let gh = GhFile {
filename: "new_name.rs".into(),
status: "renamed".into(),
additions: 0,
deletions: 0,
patch: None,
previous_filename: Some("old_name.rs".into()),
};
let df = parse_single_gh_file(gh);
assert_eq!(df.status, FileStatus::Renamed);
assert_eq!(df.old_path, Some(std::path::PathBuf::from("old_name.rs")));
assert_eq!(df.new_path, Some(std::path::PathBuf::from("new_name.rs")));
assert!(!df.is_binary);
}
#[test]
fn parse_single_gh_file_binary() {
let gh = GhFile {
filename: "image.png".into(),
status: "modified".into(),
additions: 0,
deletions: 0,
patch: None,
previous_filename: None,
};
let df = parse_single_gh_file(gh);
assert!(df.is_binary);
}
#[test]
fn convert_pr_open() {
let gh = GhPullRequest {
number: 1,
title: "test".into(),
body: Some("body".into()),
state: "open".into(),
draft: Some(false),
merged_at: None,
user: GhUser {
login: "alice".into(),
id: 1,
},
base: GhRef {
ref_name: "main".into(),
sha: "abc".into(),
},
head: GhRef {
ref_name: "feat".into(),
sha: "def".into(),
},
created_at: "2024-01-01T00:00:00Z".into(),
mergeable_state: Some("clean".into()),
mergeable: Some(true),
};
let pr = convert_pr(gh).unwrap();
assert_eq!(pr.state, PrState::Open);
assert_eq!(pr.mergeable, Some(MergeableStatus::Clean));
assert!(!pr.is_draft);
}
#[test]
fn convert_pr_merged() {
let gh = GhPullRequest {
number: 2,
title: "merged".into(),
body: None,
state: "closed".into(),
draft: None,
merged_at: Some("2024-01-02T00:00:00Z".into()),
user: GhUser {
login: "bob".into(),
id: 2,
},
base: GhRef {
ref_name: "main".into(),
sha: "abc".into(),
},
head: GhRef {
ref_name: "fix".into(),
sha: "def".into(),
},
created_at: "2024-01-01T00:00:00Z".into(),
mergeable_state: None,
mergeable: None,
};
let pr = convert_pr(gh).unwrap();
assert_eq!(pr.state, PrState::Merged);
assert_eq!(pr.body, "");
}
#[test]
fn convert_pr_list_item_open_pr_no_reviewers() {
let gh = GhPullRequestListItem {
number: 1,
title: "feat: stuff".into(),
state: "open".into(),
draft: Some(false),
merged_at: None,
user: GhUser {
login: "alice".into(),
id: 1,
},
base: GhRef {
ref_name: "main".into(),
sha: "abc".into(),
},
head: GhRef {
ref_name: "feat".into(),
sha: "def".into(),
},
updated_at: "2024-06-15T10:30:00Z".into(),
requested_reviewers: vec![],
assignees: vec![],
comments: Some(2),
review_comments: Some(3),
};
let item = convert_pr_list_item(gh, Some("bob")).unwrap();
assert_eq!(item.number, 1);
assert_eq!(item.state, PrState::Open);
assert_eq!(item.author, "alice");
assert_eq!(item.comment_count, 5);
assert!(!item.is_draft);
assert!(!item.has_review_requested_from_me);
}
#[test]
fn convert_pr_list_item_merged_state_detected_via_merged_at() {
let gh = GhPullRequestListItem {
number: 2,
title: "chore".into(),
state: "closed".into(),
draft: None,
merged_at: Some("2024-06-16T12:00:00Z".into()),
user: GhUser {
login: "bob".into(),
id: 2,
},
base: GhRef {
ref_name: "main".into(),
sha: "a".into(),
},
head: GhRef {
ref_name: "f".into(),
sha: "b".into(),
},
updated_at: "2024-06-16T12:00:00Z".into(),
requested_reviewers: vec![],
assignees: vec![],
comments: None,
review_comments: None,
};
let item = convert_pr_list_item(gh, None).unwrap();
assert_eq!(item.state, PrState::Merged);
assert_eq!(item.comment_count, 0);
}
#[test]
fn convert_pr_list_item_flags_review_requested_for_current_user() {
let gh = GhPullRequestListItem {
number: 3,
title: "fix".into(),
state: "open".into(),
draft: Some(true),
merged_at: None,
user: GhUser {
login: "alice".into(),
id: 1,
},
base: GhRef {
ref_name: "main".into(),
sha: "a".into(),
},
head: GhRef {
ref_name: "f".into(),
sha: "b".into(),
},
updated_at: "2024-06-15T10:30:00Z".into(),
requested_reviewers: vec![
GhUser {
login: "carol".into(),
id: 3,
},
GhUser {
login: "bob".into(),
id: 2,
},
],
assignees: vec![],
comments: Some(0),
review_comments: Some(0),
};
let item = convert_pr_list_item(gh, Some("bob")).unwrap();
assert!(item.is_draft);
assert!(item.has_review_requested_from_me);
assert_eq!(item.reviewers, vec!["carol".to_string(), "bob".to_string()]);
}
#[test]
fn cap_list_prs_max_clamps_to_one_and_hundred() {
assert_eq!(cap_list_prs_max(0), 1);
assert_eq!(cap_list_prs_max(30), 30);
assert_eq!(cap_list_prs_max(1000), 100);
}
#[test]
fn convert_commit_basic() {
let gh = GhCommit {
sha: "abc123def456".into(),
commit: GhCommitDetail {
message: "Fix bug\n\nDetailed description".into(),
author: GhGitActor {
name: "Alice".into(),
date: Some("2024-01-01T00:00:00Z".into()),
},
},
author: Some(GhUser {
login: "alice".into(),
id: 1,
}),
};
let ci = convert_commit(gh).unwrap();
assert_eq!(ci.id, "abc123def456");
assert_eq!(ci.short_id, "abc123d");
assert_eq!(ci.summary, "Fix bug");
assert_eq!(ci.body, Some("Detailed description".into()));
assert_eq!(ci.author, "alice");
}
#[test]
fn graphql_url_github_com() {
let forge = GitHubForge {
client: Client::new(),
base_url: "https://api.github.com".into(),
warn_handler: None,
};
assert_eq!(forge.graphql_url(), "https://api.github.com/graphql");
}
#[test]
fn graphql_url_ghe() {
let forge = GitHubForge {
client: Client::new(),
base_url: "https://github.example.com/api/v3".into(),
warn_handler: None,
};
assert_eq!(
forge.graphql_url(),
"https://github.example.com/api/graphql"
);
}
#[test]
fn validate_base_url_accepts_https_domain() {
let got = GitHubForge::validate_base_url("https://api.github.com", false).unwrap();
assert_eq!(got, "https://api.github.com");
}
#[test]
fn validate_base_url_accepts_ghe_with_trailing_slash() {
let got =
GitHubForge::validate_base_url("https://github.example.com/api/v3/", false).unwrap();
assert_eq!(got, "https://github.example.com/api/v3");
}
#[test]
fn validate_base_url_rejects_http_scheme() {
let err = GitHubForge::validate_base_url("http://api.github.com", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_non_url() {
let err = GitHubForge::validate_base_url("not a url", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_loopback_v4() {
let err = GitHubForge::validate_base_url("https://127.0.0.1", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_private_v4() {
for url in [
"https://10.0.0.1",
"https://192.168.1.1",
"https://172.16.0.1",
] {
let err = GitHubForge::validate_base_url(url, false).unwrap_err();
assert!(
matches!(err, TrvError::AuthError(_)),
"expected AuthError for {url}"
);
}
}
#[test]
fn validate_base_url_rejects_link_local_v4() {
let err = GitHubForge::validate_base_url("https://169.254.169.254", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_unspecified_v4() {
let err = GitHubForge::validate_base_url("https://0.0.0.0", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_loopback_v6() {
let err = GitHubForge::validate_base_url("https://[::1]", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_rejects_link_local_v6() {
let err = GitHubForge::validate_base_url("https://[fe80::1]", false).unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[test]
fn validate_base_url_allows_loopback_when_insecure() {
let got = GitHubForge::validate_base_url("http://127.0.0.1:8080", true).unwrap();
assert_eq!(got, "http://127.0.0.1:8080");
}
#[test]
fn with_token_insecure_allows_loopback_http() {
let forge = GitHubForge::with_token_insecure("http://127.0.0.1:1234", "tok".into());
assert!(forge.is_ok());
}
#[test]
fn is_blocked_ip_covers_cgnat() {
let ip: IpAddr = "100.64.0.1".parse().unwrap();
assert!(is_blocked_ip(&ip));
let ip: IpAddr = "100.127.255.255".parse().unwrap();
assert!(is_blocked_ip(&ip));
let ip: IpAddr = "100.128.0.1".parse().unwrap();
assert!(!is_blocked_ip(&ip));
}
#[test]
fn with_token_builds_client_with_timeouts() {
let forge = GitHubForge::with_token("https://api.github.com", "tok".into());
assert!(forge.is_ok());
}
#[test]
fn with_token_invalid_bytes_returns_static_message() {
let bad_token = "abc\ndef".to_string();
let result = GitHubForge::with_token("https://api.github.com", bad_token);
let err = match result {
Err(e) => e,
Ok(_) => panic!("expected with_token to fail for invalid header bytes"),
};
match err {
TrvError::AuthError(msg) => {
assert_eq!(
msg,
"token contains invalid header bytes; check GITHUB_TOKEN"
);
assert!(!msg.contains("abc"));
assert!(!msg.contains("def"));
}
other => panic!("expected AuthError, got {other:?}"),
}
}
#[test]
fn sanitize_error_body_strips_authorization_line() {
let body = "Something went wrong\nAuthorization: Bearer abc123\nmore text";
let got = sanitize_error_body(body);
assert!(!got.to_ascii_lowercase().contains("authorization"));
assert!(!got.contains("abc123"));
assert!(got.contains("Something went wrong"));
assert!(got.contains("more text"));
}
#[test]
fn sanitize_error_body_strips_private_token_case_insensitive() {
let body = "prefix\nprivate-token: glpat-abc\nPRIVATE-TOKEN: other\nsuffix";
let got = sanitize_error_body(body);
assert!(!got.contains("glpat-abc"));
assert!(!got.contains("other"));
assert!(got.contains("prefix"));
assert!(got.contains("suffix"));
}
#[test]
fn sanitize_error_body_strips_x_token_headers() {
let body = "line1\nx-github-token: ghp_secret\nx-gitlab-token: tok2\nline2";
let got = sanitize_error_body(body);
assert!(!got.contains("ghp_secret"));
assert!(!got.contains("tok2"));
assert!(got.contains("line1"));
assert!(got.contains("line2"));
}
#[test]
fn sanitize_error_body_truncates_large_payload() {
let body = "a".repeat(10_000);
let got = sanitize_error_body(&body);
assert!(got.len() <= MAX_ERROR_BODY_BYTES + "... [truncated]".len());
assert!(got.ends_with("... [truncated]"));
}
#[test]
fn sanitize_error_body_preserves_short_body() {
let body = "{\"message\":\"Not Found\"}";
let got = sanitize_error_body(body);
assert_eq!(got, body);
}
#[test]
fn line_looks_like_credential_header_cases() {
assert!(line_looks_like_credential_header(
"Authorization: Bearer abc"
));
assert!(line_looks_like_credential_header("authorization:x"));
assert!(line_looks_like_credential_header("Private-Token: abc"));
assert!(line_looks_like_credential_header("X-GitHub-Token: abc"));
assert!(line_looks_like_credential_header("x-token: abc"));
assert!(!line_looks_like_credential_header("Content-Type: json"));
assert!(!line_looks_like_credential_header("message: bad request"));
assert!(!line_looks_like_credential_header("no colon here"));
assert!(!line_looks_like_credential_header("X-Request-Id: abc"));
}
#[test]
fn emit_warning_routes_through_handler_when_installed() {
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let forge = GitHubForge::with_token("https://api.github.com", "tok".into())
.unwrap()
.with_warn_handler(Arc::new(move |msg| {
sink.lock().unwrap().push(msg);
}));
forge.emit_warning("hello".to_string());
let got = captured.lock().unwrap().clone();
assert_eq!(got, vec!["hello".to_string()]);
}
#[test]
fn emit_warning_falls_back_without_handler() {
let forge = GitHubForge::with_token("https://api.github.com", "tok".into()).unwrap();
assert!(forge.warn_handler.is_none());
forge.emit_warning("fallback".to_string());
}
}