use std::process::Command;
use super::refparse::{RefKind, RefTarget, parse_ref_url};
pub trait ForgeFetcher {
fn fetch_payload(&self, target: &RefTarget) -> Result<String, String>;
fn list_open_refs(
&self,
host: &str,
org: &str,
repo: &str,
since: Option<&str>,
) -> Result<Vec<RefTarget>, String>;
}
pub struct RealForge {
binary: String,
}
impl RealForge {
pub fn from_env() -> Self {
let binary =
std::env::var("PLAN_ARCHIVE_FORGE_CLI").unwrap_or_else(|_| "forge-cli".to_string());
Self { binary }
}
fn provider_for(host: &str) -> &'static str {
if host == "github.com" {
"github"
} else {
"gitlab"
}
}
fn run(&self, args: &[&str]) -> Result<String, String> {
let out = Command::new(&self.binary)
.args(args)
.output()
.map_err(|e| format!("spawn {}: {e}", self.binary))?;
if !out.status.success() {
return Err(format!(
"forge-cli exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
}
impl ForgeFetcher for RealForge {
fn fetch_payload(&self, target: &RefTarget) -> Result<String, String> {
let provider = Self::provider_for(&target.host);
let repo_slug = format!("{}/{}", target.org_or_group_path, target.repo);
let number = target.number.to_string();
let base = || {
vec![
"--provider",
provider,
"--repo",
&repo_slug,
"--format",
"json",
]
};
match target.kind {
RefKind::Issue => {
let mut args = base();
args.extend(["issue", "view", number.as_str(), "--with-comments"]);
self.run(&args)
}
RefKind::Pull | RefKind::MergeRequest => {
let mut view_args = base();
view_args.extend(["pr", "view", number.as_str()]);
let view = self.run(&view_args)?;
let mut comment_args = base();
comment_args.extend(["pr", "comments", number.as_str()]);
let comments = self.run(&comment_args)?;
merge_pr_comments(&view, &comments)
}
}
}
fn list_open_refs(
&self,
host: &str,
org: &str,
repo: &str,
since: Option<&str>,
) -> Result<Vec<RefTarget>, String> {
let provider = Self::provider_for(host);
let repo_slug = format!("{org}/{repo}");
let mut targets = Vec::new();
for (subcommand, _kind) in [("issue", RefKind::Issue), ("pr", RefKind::Pull)] {
let mut args = vec![
"--provider".to_string(),
provider.to_string(),
"--repo".to_string(),
repo_slug.clone(),
"--format".to_string(),
"json".to_string(),
subcommand.to_string(),
"list".to_string(),
"--state".to_string(),
"open".to_string(),
];
if let Some(since) = since {
args.push("--since".to_string());
args.push(since.to_string());
}
let out = Command::new(&self.binary)
.args(&args)
.output()
.map_err(|e| format!("spawn {}: {e}", self.binary))?;
if !out.status.success() {
return Err(format!(
"forge-cli {subcommand} list exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let body = String::from_utf8_lossy(&out.stdout);
targets.extend(parse_listing(&body, host, org, repo, subcommand));
}
Ok(targets)
}
}
fn merge_pr_comments(view_json: &str, comments_json: &str) -> Result<String, String> {
let mut view: serde_json::Value =
serde_json::from_str(view_json).map_err(|e| format!("parse pr view: {e}"))?;
let comments: serde_json::Value =
serde_json::from_str(comments_json).map_err(|e| format!("parse pr comments: {e}"))?;
let stream = comments
.get("data")
.and_then(|d| d.get("comments"))
.cloned()
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
if let Some(data) = view.get_mut("data").and_then(|d| d.as_object_mut()) {
data.insert("comments".to_string(), stream);
}
serde_json::to_string(&view).map_err(|e| format!("serialize merged pr payload: {e}"))
}
fn parse_listing(
body: &str,
host: &str,
org: &str,
repo: &str,
subcommand: &str,
) -> Vec<RefTarget> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return Vec::new();
};
let items = value
.get("data")
.and_then(|d| {
d.get("items")
.or_else(|| d.get("issues"))
.or_else(|| d.get("pulls"))
})
.or_else(|| value.get("items"))
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for item in items {
if let Some(url) = item.get("url").and_then(|u| u.as_str())
&& let Some(target) = parse_ref_url(url)
{
out.push(target);
continue;
}
if let Some(number) = item.get("number").and_then(|n| n.as_u64()) {
let kind = if subcommand == "issue" {
RefKind::Issue
} else if host == "github.com" {
RefKind::Pull
} else {
RefKind::MergeRequest
};
out.push(RefTarget {
host: host.to_string(),
org_or_group_path: org.to_string(),
repo: repo.to_string(),
kind,
number,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_mapping() {
assert_eq!(RealForge::provider_for("github.com"), "github");
assert_eq!(RealForge::provider_for("gitlab.com"), "gitlab");
assert_eq!(RealForge::provider_for("gitlab.example.com"), "gitlab");
}
#[test]
fn listing_reads_url_field() {
let body = r#"{"data":{"items":[
{"number":1,"url":"https://github.com/org/repo/issues/1"},
{"number":2,"url":"https://github.com/org/repo/issues/2"}
]}}"#;
let targets = parse_listing(body, "github.com", "org", "repo", "issue");
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].number, 1);
assert_eq!(targets[0].kind, RefKind::Issue);
}
#[test]
fn listing_falls_back_to_number() {
let body = r#"{"data":{"items":[{"number":9}]}}"#;
let targets = parse_listing(body, "gitlab.example.com", "g/p", "ingest", "pr");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].kind, RefKind::MergeRequest);
assert_eq!(targets[0].number, 9);
assert_eq!(targets[0].org_or_group_path, "g/p");
}
#[test]
fn listing_empty_on_garbage() {
assert!(parse_listing("not json", "github.com", "o", "r", "issue").is_empty());
}
#[test]
fn merge_pr_comments_embeds_stream() {
let view = r#"{"schema_version":"v1","ok":true,"data":{"number":542,"title":"x"}}"#;
let comments =
r#"{"ok":true,"data":{"number":542,"comments":[{"author":"a","body":"hi"}]}}"#;
let merged = merge_pr_comments(view, comments).unwrap();
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert_eq!(v["data"]["comments"].as_array().unwrap().len(), 1);
assert_eq!(v["data"]["title"], "x");
}
#[test]
fn merge_pr_comments_defaults_empty_when_absent() {
let view = r#"{"data":{"number":1}}"#;
let comments = r#"{"data":{"number":1}}"#;
let merged = merge_pr_comments(view, comments).unwrap();
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert!(v["data"]["comments"].as_array().unwrap().is_empty());
}
#[test]
fn merge_pr_comments_errors_on_garbage() {
assert!(merge_pr_comments("not json", "{}").is_err());
}
}