use serde::{Deserialize, Serialize};
use crate::integrations::github::{GithubClient, GithubError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrMetadata {
pub number: u64,
pub title: String,
pub html_url: String,
pub state: String,
pub user: PrUser,
pub base: PrRef,
pub head: PrRef,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrUser {
pub login: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrRef {
#[serde(rename = "ref")]
pub branch: String,
pub sha: String,
#[serde(default)]
pub label: Option<String>,
}
pub async fn fetch_pr_metadata(
client: &GithubClient,
owner: &str,
repo: &str,
pr: u64,
token: &str,
) -> Result<PrMetadata, GithubError> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr}");
let resp = client
.http
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", &client.user_agent)
.send()
.await
.map_err(|e| GithubError::Transport(format!("GET {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| GithubError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(GithubError::Api {
status: status.as_u16(),
body,
});
}
serde_json::from_str(&body)
.map_err(|e| GithubError::Transport(format!("parse PR metadata from {url}: {e}")))
}
pub async fn fetch_pr_diff(
client: &GithubClient,
owner: &str,
repo: &str,
pr: u64,
token: &str,
) -> Result<String, GithubError> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr}");
let resp = client
.http
.get(&url)
.header("Accept", "application/vnd.github.v3.diff")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", &client.user_agent)
.send()
.await
.map_err(|e| GithubError::Transport(format!("GET {url} (diff): {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| GithubError::Transport(format!("read body of {url} (diff): {e}")))?;
if !status.is_success() {
return Err(GithubError::Api {
status: status.as_u16(),
body,
});
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_metadata_deserialises_minimal_json() {
let base_sha = "baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let head_sha = "feeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; let json = format!(
r#"{{
"number": 42,
"title": "Add feature X",
"html_url": "https://github.com/acme/backend/pull/42",
"state": "open",
"user": {{ "login": "alice" }},
"base": {{ "ref": "main", "sha": "{base_sha}", "label": "acme:main" }},
"head": {{ "ref": "feature/x", "sha": "{head_sha}", "label": "alice:feature/x" }},
"body": "This PR adds feature X."
}}"#
);
let meta: PrMetadata = serde_json::from_str(&json).expect("should deserialise");
assert_eq!(meta.number, 42);
assert_eq!(meta.title, "Add feature X");
assert_eq!(meta.user.login, "alice");
assert_eq!(meta.base.sha, base_sha);
assert_eq!(meta.head.sha, head_sha);
assert_eq!(meta.body.as_deref(), Some("This PR adds feature X."));
}
#[test]
fn pr_metadata_null_body_defaults_to_none() {
let json = r#"{
"number": 1,
"title": "Fix typo",
"html_url": "https://github.com/o/r/pull/1",
"state": "open",
"user": { "login": "bob" },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "fix/typo", "sha": "bbb" }
}"#;
let meta: PrMetadata = serde_json::from_str(json).expect("should deserialise");
assert!(
meta.body.is_none(),
"missing body field should deserialise as None"
);
}
#[test]
fn pr_metadata_ignores_extra_fields() {
let json = r#"{
"number": 99,
"title": "Test",
"html_url": "https://github.com/o/r/pull/99",
"state": "open",
"user": { "login": "eve", "id": 12345, "avatar_url": "https://example.com/e.png" },
"base": { "ref": "main", "sha": "aaa", "repo": { "name": "r" } },
"head": { "ref": "br", "sha": "bbb" },
"draft": false,
"merged": null
}"#;
let meta: PrMetadata = serde_json::from_str(json).expect("extra fields should be ignored");
assert_eq!(meta.number, 99);
assert_eq!(meta.user.login, "eve");
}
#[tokio::test]
async fn fetch_pr_diff_transport_error_on_unreachable_host() {
let client = GithubClient::with_timeout(std::time::Duration::from_millis(200))
.expect("TLS init should succeed in tests");
let result = client
.http
.get("http://127.0.0.1:1/repos/o/r/pulls/1")
.header("Accept", "application/vnd.github.v3.diff")
.header("Authorization", "Bearer dummy")
.header("User-Agent", &client.user_agent)
.send()
.await;
assert!(result.is_err(), "connection to port 1 must fail");
}
}