use anyhow::{Context, Result};
use super::http::ForgeClient;
use super::types::{ChecksStatus, IssueComment, MergeMethod, PrMergeability, PrState, PullRequest, ReviewSummary};
use super::Forge;
pub struct GitHubForge {
client: ForgeClient,
}
impl GitHubForge {
pub fn new(client: ForgeClient) -> Self {
Self { client }
}
}
fn parse_checks_status(
check_runs: &serde_json::Value,
commit_status: &serde_json::Value,
) -> ChecksStatus {
let runs = check_runs["check_runs"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or_default();
let statuses = commit_status["statuses"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or_default();
if runs.is_empty() && statuses.is_empty() {
return ChecksStatus::None;
}
let mut has_pending = false;
let mut has_failure = false;
for run in runs {
match run["conclusion"].as_str() {
Some("success") | Some("skipped") | Some("neutral") => {}
None if run["status"].as_str() == Some("in_progress")
|| run["status"].as_str() == Some("queued") =>
{
has_pending = true;
}
_ => has_failure = true,
}
}
for s in statuses {
match s["state"].as_str() {
Some("success") => {}
Some("pending") => has_pending = true,
_ => has_failure = true,
}
}
if has_failure {
ChecksStatus::Fail
} else if has_pending {
ChecksStatus::Pending
} else {
ChecksStatus::Pass
}
}
fn parse_review_summary(reviews: &[serde_json::Value]) -> ReviewSummary {
let mut latest: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for review in reviews {
let user = review["user"]["login"].as_str().unwrap_or_default();
let state = review["state"].as_str().unwrap_or_default();
if !user.is_empty()
&& matches!(state, "APPROVED" | "CHANGES_REQUESTED" | "DISMISSED")
{
latest.insert(user.to_string(), state.to_string());
}
}
let approved_count = latest.values().filter(|s| *s == "APPROVED").count() as u32;
let changes_requested = latest.values().any(|s| s == "CHANGES_REQUESTED");
ReviewSummary {
approved_count,
changes_requested,
}
}
impl Forge for GitHubForge {
fn list_open_prs(
&self,
owner: &str,
repo: &str,
) -> Result<Vec<PullRequest>> {
let path = format!("repos/{owner}/{repo}/pulls?state=open&per_page=100");
let items = self.client.get_paginated(&path)?;
serde_json::from_value(serde_json::Value::Array(items))
.context("failed to parse PR list response")
}
fn create_pr(
&self,
owner: &str,
repo: &str,
title: &str,
body: &str,
head: &str,
base: &str,
draft: bool,
) -> Result<PullRequest> {
let path = format!("repos/{owner}/{repo}/pulls");
let mut json_body = serde_json::json!({
"title": title,
"head": head,
"base": base,
"body": body,
});
if draft {
json_body["draft"] = serde_json::json!(true);
}
let output = self.client.post(&path, &json_body)?;
serde_json::from_value(output).context("failed to parse created PR response")
}
fn update_pr_base(
&self,
owner: &str,
repo: &str,
number: u64,
base: &str,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
self.client.patch(&path, &serde_json::json!({ "base": base }))?;
Ok(())
}
fn request_reviewers(
&self,
owner: &str,
repo: &str,
number: u64,
reviewers: &[String],
) -> Result<()> {
if reviewers.is_empty() {
return Ok(());
}
let path = format!("repos/{owner}/{repo}/pulls/{number}/requested_reviewers");
self.client.post(&path, &serde_json::json!({ "reviewers": reviewers }))?;
Ok(())
}
fn list_comments(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<IssueComment>> {
let path = format!("repos/{owner}/{repo}/issues/{number}/comments?per_page=100");
let items = self.client.get_paginated(&path)?;
serde_json::from_value(serde_json::Value::Array(items))
.context("failed to parse comments response")
}
fn create_comment(
&self,
owner: &str,
repo: &str,
number: u64,
body: &str,
) -> Result<IssueComment> {
let path = format!("repos/{owner}/{repo}/issues/{number}/comments");
let output = self.client.post(&path, &serde_json::json!({ "body": body }))?;
serde_json::from_value(output).context("failed to parse created comment response")
}
fn update_comment(
&self,
owner: &str,
repo: &str,
comment_id: u64,
body: &str,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/issues/comments/{comment_id}");
self.client.patch(&path, &serde_json::json!({ "body": body }))?;
Ok(())
}
fn update_pr_body(
&self,
owner: &str,
repo: &str,
number: u64,
body: &str,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
self.client.patch(&path, &serde_json::json!({ "body": body }))?;
Ok(())
}
fn mark_pr_ready(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
let pr = self.client.get(&path)?;
let node_id = pr["node_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("PR response missing node_id field"))?;
let query = "mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { clientMutationId } }";
self.client.graphql(
"graphql",
query,
&serde_json::json!({ "id": node_id }),
)?;
Ok(())
}
fn find_merged_pr(
&self,
owner: &str,
repo: &str,
head: &str,
) -> Result<Option<PullRequest>> {
let encoded_head = super::http::url_encode(head);
let path = format!(
"repos/{owner}/{repo}/pulls?head={owner}:{encoded_head}&state=closed"
);
let output = self.client.get(&path)?;
let prs: Vec<PullRequest> = serde_json::from_value(output)
.context("failed to parse closed PR list response")?;
Ok(prs.into_iter().find(|pr| pr.merged_at.is_some()))
}
fn get_authenticated_user(&self) -> Result<String> {
let output = self.client.get("user")?;
output["login"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("user response missing login field"))
}
fn merge_pr(
&self,
owner: &str,
repo: &str,
number: u64,
method: MergeMethod,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}/merge");
self.client.put(&path, &serde_json::json!({ "merge_method": method.to_string() }))?;
Ok(())
}
fn get_pr_checks_status(
&self,
owner: &str,
repo: &str,
head_ref: &str,
) -> Result<ChecksStatus> {
let encoded_ref = super::http::url_encode(head_ref);
let check_runs_path =
format!("repos/{owner}/{repo}/commits/{encoded_ref}/check-runs");
let check_runs = self.client.get(&check_runs_path)?;
let status_path =
format!("repos/{owner}/{repo}/commits/{encoded_ref}/status");
let status = self.client.get(&status_path)?;
Ok(parse_checks_status(&check_runs, &status))
}
fn get_pr_reviews(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<ReviewSummary> {
let path = format!("repos/{owner}/{repo}/pulls/{number}/reviews?per_page=100");
let items = self.client.get_paginated(&path)?;
Ok(parse_review_summary(&items))
}
fn get_pr_state(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<PrState> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
let pr = self.client.get(&path)?;
Ok(PrState {
merged: pr["merged_at"].is_string(),
state: pr["state"].as_str().unwrap_or("unknown").to_string(),
})
}
fn get_pr_mergeability(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<PrMergeability> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
let pr = self.client.get(&path)?;
let mergeable = pr["mergeable"].as_bool();
let mergeable_state = pr["mergeable_state"]
.as_str()
.unwrap_or("unknown")
.to_string();
Ok(PrMergeability {
mergeable,
mergeable_state,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_checks_all_passing() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": "success", "status": "completed"},
{"conclusion": "skipped", "status": "completed"},
]
});
let status = serde_json::json!({
"statuses": [
{"state": "success"}
]
});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Pass);
}
#[test]
fn test_parse_checks_pending() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": null, "status": "in_progress"},
]
});
let status = serde_json::json!({"statuses": []});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Pending);
}
#[test]
fn test_parse_checks_failure() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": "failure", "status": "completed"},
]
});
let status = serde_json::json!({"statuses": []});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Fail);
}
#[test]
fn test_parse_checks_none() {
let check_runs = serde_json::json!({"check_runs": []});
let status = serde_json::json!({"statuses": []});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::None);
}
#[test]
fn test_parse_checks_mixed_failure_wins() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": "success", "status": "completed"},
{"conclusion": "failure", "status": "completed"},
]
});
let status = serde_json::json!({
"statuses": [{"state": "pending"}]
});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Fail);
}
#[test]
fn test_parse_checks_queued_is_pending() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": null, "status": "queued"},
]
});
let status = serde_json::json!({"statuses": []});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Pending);
}
#[test]
fn test_parse_checks_neutral_passes() {
let check_runs = serde_json::json!({
"check_runs": [
{"conclusion": "neutral", "status": "completed"},
]
});
let status = serde_json::json!({"statuses": []});
assert_eq!(parse_checks_status(&check_runs, &status), ChecksStatus::Pass);
}
#[test]
fn test_review_latest_state_wins() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "CHANGES_REQUESTED"}),
];
let summary = parse_review_summary(&reviews);
assert_eq!(summary.approved_count, 0);
assert!(summary.changes_requested);
}
#[test]
fn test_review_commented_does_not_override() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "COMMENTED"}),
];
let summary = parse_review_summary(&reviews);
assert_eq!(summary.approved_count, 1);
assert!(!summary.changes_requested);
}
#[test]
fn test_review_pending_does_not_override() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "PENDING"}),
];
let summary = parse_review_summary(&reviews);
assert_eq!(summary.approved_count, 1);
assert!(!summary.changes_requested);
}
#[test]
fn test_review_multiple_reviewers() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "bob"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "charlie"}, "state": "CHANGES_REQUESTED"}),
];
let summary = parse_review_summary(&reviews);
assert_eq!(summary.approved_count, 2);
assert!(summary.changes_requested);
}
#[test]
fn test_review_dismissed_clears_approval() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "DISMISSED"}),
];
let summary = parse_review_summary(&reviews);
assert_eq!(summary.approved_count, 0);
assert!(!summary.changes_requested);
}
#[test]
fn test_parse_mergeability_clean() {
let pr = serde_json::json!({"mergeable": true, "mergeable_state": "clean"});
assert_eq!(pr["mergeable"].as_bool(), Some(true));
assert_eq!(pr["mergeable_state"].as_str(), Some("clean"));
}
#[test]
fn test_parse_mergeability_dirty() {
let pr = serde_json::json!({"mergeable": false, "mergeable_state": "dirty"});
assert_eq!(pr["mergeable"].as_bool(), Some(false));
assert_eq!(pr["mergeable_state"].as_str(), Some("dirty"));
}
#[test]
fn test_parse_pr_basic_fields() {
let json = r#"{
"number": 42,
"html_url": "https://github.com/owner/repo/pull/42",
"title": "Add auth",
"body": "Auth implementation",
"base": {"ref": "main", "label": "owner:main"},
"head": {"ref": "auth", "label": "owner:auth"},
"draft": false,
"node_id": "PR_kwDOABC123",
"merged_at": null
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert_eq!(pr.number, 42);
assert_eq!(pr.html_url, "https://github.com/owner/repo/pull/42");
assert_eq!(pr.title, "Add auth");
assert_eq!(pr.base.ref_name, "main");
assert_eq!(pr.head.ref_name, "auth");
assert!(!pr.draft);
assert_eq!(pr.node_id, "PR_kwDOABC123");
assert!(pr.merged_at.is_none());
}
#[test]
fn test_parse_pr_draft() {
let json = r#"{
"number": 7,
"html_url": "https://github.com/o/r/pull/7",
"title": "WIP",
"body": null,
"base": {"ref": "main", "label": ""},
"head": {"ref": "feat", "label": ""},
"draft": true,
"node_id": "PR_kwDOXYZ"
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert!(pr.draft);
assert!(pr.body.is_none());
}
#[test]
fn test_parse_pr_merged() {
let json = r#"{
"number": 99,
"html_url": "https://github.com/o/r/pull/99",
"title": "Done",
"body": "merged",
"base": {"ref": "main", "label": ""},
"head": {"ref": "feat", "label": ""},
"draft": false,
"node_id": "",
"merged_at": "2024-06-15T10:30:00Z"
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert_eq!(pr.merged_at.as_deref(), Some("2024-06-15T10:30:00Z"));
}
#[test]
fn test_parse_pr_requested_reviewers() {
let json = r#"{
"number": 42,
"html_url": "https://github.com/o/r/pull/42",
"title": "Auth",
"body": null,
"base": {"ref": "main", "label": ""},
"head": {"ref": "auth", "label": ""},
"draft": false,
"node_id": "",
"requested_reviewers": [
{"login": "alice", "id": 1},
{"login": "bob", "id": 2}
]
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert_eq!(pr.requested_reviewers, vec!["alice", "bob"]);
}
#[test]
fn test_parse_pr_null_requested_reviewers() {
let json = r#"{
"number": 42,
"html_url": "https://github.com/o/r/pull/42",
"title": "Auth",
"body": null,
"base": {"ref": "main", "label": ""},
"head": {"ref": "auth", "label": ""},
"draft": false,
"node_id": "",
"requested_reviewers": null
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert!(pr.requested_reviewers.is_empty());
}
#[test]
fn test_parse_pr_no_requested_reviewers() {
let json = r#"{
"number": 42,
"html_url": "https://github.com/o/r/pull/42",
"title": "Auth",
"body": null,
"base": {"ref": "main", "label": ""},
"head": {"ref": "auth", "label": ""},
"draft": false,
"node_id": ""
}"#;
let pr: PullRequest = serde_json::from_str(json).unwrap();
assert!(pr.requested_reviewers.is_empty());
}
}