use anyhow::{Context, Result};
use super::http::ForgeClient;
use super::types::{
ChecksStatus, IssueComment, MergeMethod, PrMergeability, PrState, PullRequest, ReviewSummary,
};
use super::Forge;
pub struct ForgejoForge {
client: ForgeClient,
}
impl ForgejoForge {
pub fn new(client: ForgeClient) -> Self {
Self { client }
}
}
fn parse_combined_status(combined: &serde_json::Value) -> ChecksStatus {
let statuses = combined["statuses"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or_default();
if statuses.is_empty() {
return ChecksStatus::None;
}
let mut has_pending = false;
let mut has_failure = false;
for s in statuses {
match s["status"].as_str() {
Some("success") => {}
Some("pending") => has_pending = true,
Some("error") | Some("failure") => has_failure = true,
Some("warning") => {}
_ => has_pending = true,
}
}
if has_failure {
ChecksStatus::Fail
} else if has_pending {
ChecksStatus::Pending
} else {
ChecksStatus::Pass
}
}
fn parse_reviews(items: &[serde_json::Value]) -> ReviewSummary {
let mut latest: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for review in items {
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" | "REQUEST_CHANGES" | "REJECTED"
)
{
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 == "REQUEST_CHANGES" || s == "REJECTED");
ReviewSummary {
approved_count,
changes_requested,
}
}
fn parse_mergeability(pr: &serde_json::Value) -> PrMergeability {
let mergeable = pr["mergeable"].as_bool();
PrMergeability {
mergeable,
mergeable_state: if mergeable == Some(true) {
"clean".to_string()
} else if mergeable == Some(false) {
"dirty".to_string()
} else {
"unknown".to_string()
},
}
}
impl Forge for ForgejoForge {
fn list_open_prs(&self, owner: &str, repo: &str) -> Result<Vec<PullRequest>> {
let path = format!("repos/{owner}/{repo}/pulls?state=open");
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 json_body = serde_json::json!({
"title": title,
"body": body,
"head": head,
"base": base,
"draft": draft,
});
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}");
let json_body = serde_json::json!({ "base": base });
self.client.patch(&path, &json_body)?;
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");
let json_body = serde_json::json!({ "reviewers": reviewers });
self.client.post(&path, &json_body)?;
Ok(())
}
fn list_comments(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<IssueComment>> {
let path = format!("repos/{owner}/{repo}/issues/{number}/comments");
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 json_body = serde_json::json!({ "body": body });
let output = self.client.post(&path, &json_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}");
let json_body = serde_json::json!({ "body": body });
self.client.patch(&path, &json_body)?;
Ok(())
}
fn update_pr_body(
&self,
owner: &str,
repo: &str,
number: u64,
body: &str,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
let json_body = serde_json::json!({ "body": body });
self.client.patch(&path, &json_body)?;
Ok(())
}
fn mark_pr_ready(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}");
let json_body = serde_json::json!({ "draft": false });
self.client.patch(&path, &json_body)?;
Ok(())
}
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 find_merged_pr(
&self,
owner: &str,
repo: &str,
head: &str,
) -> Result<Option<PullRequest>> {
let base_path = format!("repos/{owner}/{repo}/pulls?state=closed");
let max_pages = 5u32;
let mut hit_cap = false;
for page in 1..=max_pages {
let paged = format!("{base_path}&page={page}&limit=50");
let body = self.client.get(&paged)?;
let prs: Vec<PullRequest> =
serde_json::from_value(body).context("failed to parse closed PR list response")?;
if prs.is_empty() {
break;
}
if let Some(pr) = prs
.into_iter()
.find(|pr| pr.head.ref_name == head && pr.merged_at.is_some())
{
return Ok(Some(pr));
}
if page == max_pages {
hit_cap = true;
}
}
if hit_cap {
eprintln!(
"warning: scanned 250 closed PRs without finding a merged PR for '{head}'; \
result may be incomplete on repos with many closed PRs"
);
}
Ok(None)
}
fn merge_pr(
&self,
owner: &str,
repo: &str,
number: u64,
method: MergeMethod,
) -> Result<()> {
let path = format!("repos/{owner}/{repo}/pulls/{number}/merge");
let do_value = match method {
MergeMethod::Squash => "squash",
MergeMethod::Merge => "merge",
MergeMethod::Rebase => "rebase",
};
let json_body = serde_json::json!({ "Do": do_value });
self.client.post(&path, &json_body)?;
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 path = format!("repos/{owner}/{repo}/commits/{encoded_ref}/status");
let output = self.client.get(&path)?;
Ok(parse_combined_status(&output))
}
fn get_pr_reviews(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<ReviewSummary> {
let path = format!("repos/{owner}/{repo}/pulls/{number}/reviews");
let items = self.client.get_paginated(&path)?;
Ok(parse_reviews(&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"].as_bool().unwrap_or(false),
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 output = self.client.get(&path)?;
Ok(parse_mergeability(&output))
}
}
#[cfg(test)]
mod tests {
use super::*;
const FORGEJO_PR_RESPONSE: &str = r#"{
"number": 42,
"html_url": "https://codeberg.org/owner/repo/pulls/42",
"title": "Add authentication",
"body": "Implements basic auth flow",
"base": {
"ref": "main",
"label": "owner:main"
},
"head": {
"ref": "auth",
"label": "owner:auth"
},
"draft": false,
"merged_at": null
}"#;
const FORGEJO_DRAFT_PR: &str = r#"{
"number": 7,
"html_url": "https://codeberg.org/owner/repo/pulls/7",
"title": "Draft feature",
"body": null,
"base": {
"ref": "develop",
"label": "owner:develop"
},
"head": {
"ref": "draft-feature",
"label": "owner:draft-feature"
},
"draft": true,
"merged_at": null
}"#;
const FORGEJO_MERGED_PR: &str = r#"{
"number": 99,
"html_url": "https://codeberg.org/owner/repo/pulls/99",
"title": "Already merged",
"body": "This was merged",
"base": {
"ref": "main",
"label": "owner:main"
},
"head": {
"ref": "old-feature",
"label": "owner:old-feature"
},
"draft": false,
"merged_at": "2024-06-15T10:30:00Z"
}"#;
const FORGEJO_FORK_PR: &str = r#"{
"number": 15,
"html_url": "https://codeberg.org/owner/repo/pulls/15",
"title": "Fork contribution",
"body": "From a fork",
"base": {
"ref": "main",
"label": "owner:main"
},
"head": {
"ref": "feature",
"label": "fork-owner:feature"
},
"draft": false,
"merged_at": null
}"#;
#[test]
fn test_parse_pr_basic_fields() {
let pr: PullRequest = serde_json::from_str(FORGEJO_PR_RESPONSE).unwrap();
assert_eq!(pr.number, 42);
assert_eq!(pr.html_url, "https://codeberg.org/owner/repo/pulls/42");
assert_eq!(pr.title, "Add authentication");
assert_eq!(pr.body.as_deref(), Some("Implements basic auth flow"));
assert_eq!(pr.base.ref_name, "main");
assert_eq!(pr.head.ref_name, "auth");
assert!(!pr.draft);
assert!(pr.merged_at.is_none());
assert!(pr.node_id.is_empty());
}
#[test]
fn test_parse_pr_draft() {
let pr: PullRequest = serde_json::from_str(FORGEJO_DRAFT_PR).unwrap();
assert_eq!(pr.number, 7);
assert!(pr.draft);
assert!(pr.body.is_none());
assert_eq!(pr.base.ref_name, "develop");
}
#[test]
fn test_parse_pr_merged() {
let pr: PullRequest = serde_json::from_str(FORGEJO_MERGED_PR).unwrap();
assert_eq!(pr.number, 99);
assert_eq!(pr.merged_at.as_deref(), Some("2024-06-15T10:30:00Z"));
}
#[test]
fn test_parse_pr_fork_label() {
let pr: PullRequest = serde_json::from_str(FORGEJO_FORK_PR).unwrap();
assert_eq!(pr.head.label, "fork-owner:feature");
}
#[test]
fn test_parse_pr_same_repo_label() {
let pr: PullRequest = serde_json::from_str(FORGEJO_PR_RESPONSE).unwrap();
assert_eq!(pr.head.label, "owner:auth");
}
#[test]
fn test_fork_filtered_by_build_pr_map() {
let same_repo: PullRequest = serde_json::from_str(FORGEJO_PR_RESPONSE).unwrap();
let fork: PullRequest = serde_json::from_str(FORGEJO_FORK_PR).unwrap();
let map = crate::forge::build_pr_map(vec![same_repo, fork], "owner");
assert_eq!(map.len(), 1);
assert!(map.contains_key("auth"));
}
#[test]
fn test_parse_pr_list() {
let json = format!("[{FORGEJO_PR_RESPONSE}, {FORGEJO_DRAFT_PR}]");
let prs: Vec<PullRequest> = serde_json::from_str(&json).unwrap();
assert_eq!(prs.len(), 2);
assert_eq!(prs[0].number, 42);
assert_eq!(prs[1].number, 7);
}
#[test]
fn test_parse_comment() {
let json = r#"{"id": 301, "body": "Looks good to me!"}"#;
let comment: IssueComment = serde_json::from_str(json).unwrap();
assert_eq!(comment.id, 301);
assert_eq!(comment.body.as_deref(), Some("Looks good to me!"));
}
#[test]
fn test_parse_comment_stack_marker() {
let json = r#"{"id": 500, "body": "<!-- jjpr:stack-info -->\nStack content"}"#;
let comment: IssueComment = serde_json::from_str(json).unwrap();
assert_eq!(comment.id, 500);
assert!(comment
.body
.as_deref()
.unwrap()
.contains("<!-- jjpr:stack-info -->"));
}
#[test]
fn test_ci_status_mapping() {
let cases = vec![
(vec!["success"], ChecksStatus::Pass),
(vec!["pending"], ChecksStatus::Pending),
(vec!["failure"], ChecksStatus::Fail),
(vec!["error"], ChecksStatus::Fail),
(vec!["warning"], ChecksStatus::Pass),
(vec!["success", "pending"], ChecksStatus::Pending),
(vec!["success", "failure"], ChecksStatus::Fail),
];
for (statuses, expected) in cases {
let items: Vec<serde_json::Value> = statuses
.iter()
.map(|s| serde_json::json!({"status": s}))
.collect();
let combined = serde_json::json!({"statuses": items});
let result = parse_combined_status(&combined);
assert_eq!(result, expected, "statuses {statuses:?} should map correctly");
}
}
#[test]
fn test_ci_status_empty() {
let combined = serde_json::json!({"statuses": []});
assert_eq!(parse_combined_status(&combined), ChecksStatus::None);
}
#[test]
fn test_review_counting() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "bob"}, "state": "REQUEST_CHANGES"}),
serde_json::json!({"user": {"login": "charlie"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "REQUEST_CHANGES"}),
];
let summary = parse_reviews(&reviews);
assert_eq!(summary.approved_count, 1); assert!(summary.changes_requested); }
#[test]
fn test_review_skips_comment_state() {
let reviews = vec![
serde_json::json!({"user": {"login": "alice"}, "state": "APPROVED"}),
serde_json::json!({"user": {"login": "alice"}, "state": "COMMENTED"}),
];
let summary = parse_reviews(&reviews);
assert_eq!(summary.approved_count, 1);
assert!(!summary.changes_requested);
}
#[test]
fn test_mergeability_mapping() {
let mergeable = serde_json::json!({"mergeable": true});
let result = parse_mergeability(&mergeable);
assert_eq!(result.mergeable, Some(true));
assert_eq!(result.mergeable_state, "clean");
let not_mergeable = serde_json::json!({"mergeable": false});
let result = parse_mergeability(¬_mergeable);
assert_eq!(result.mergeable, Some(false));
assert_eq!(result.mergeable_state, "dirty");
let unknown = serde_json::json!({});
let result = parse_mergeability(&unknown);
assert_eq!(result.mergeable, None);
assert_eq!(result.mergeable_state, "unknown");
}
#[test]
fn test_merge_method_do_field() {
let squash = serde_json::json!({ "Do": "squash" });
assert_eq!(squash["Do"].as_str().unwrap(), "squash");
let merge = serde_json::json!({ "Do": "merge" });
assert_eq!(merge["Do"].as_str().unwrap(), "merge");
let rebase = serde_json::json!({ "Do": "rebase" });
assert_eq!(rebase["Do"].as_str().unwrap(), "rebase");
}
}