use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoRef {
pub owner: String,
pub name: String,
}
impl RepoRef {
pub fn full_name(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
pub number: u64,
pub title: String,
pub body: String,
pub labels: Vec<String>,
pub author: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitRef {
pub sha: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequest {
pub id: String,
pub number: u64,
pub title: String,
pub body: String,
pub head_branch: String,
pub base_branch: String,
pub url: String,
pub state: PrState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrState {
Open,
Closed,
Merged,
}
#[derive(Debug, Clone)]
pub struct CreatePrParams {
pub title: String,
pub body: String,
pub head_branch: String,
pub base_branch: String,
pub labels: Vec<String>,
pub draft: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MergeMethod {
Merge,
Squash,
Rebase,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckStatus {
pub state: CheckState,
pub checks: Vec<CheckRun>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CheckState {
Pending,
Success,
Failure,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckRun {
pub name: String,
pub status: String,
pub conclusion: Option<String>,
}
#[async_trait]
pub trait GitForge: Send + Sync {
fn name(&self) -> &str;
async fn get_issue(&self, repo: &RepoRef, issue_ref: &str) -> anyhow::Result<Issue>;
async fn create_pull_request(
&self,
repo: &RepoRef,
params: CreatePrParams,
) -> anyhow::Result<PullRequest>;
async fn add_comment(
&self,
repo: &RepoRef,
target_number: u64,
body: &str,
) -> anyhow::Result<()>;
async fn merge_pull_request(
&self,
repo: &RepoRef,
pr_number: u64,
method: MergeMethod,
) -> anyhow::Result<()>;
async fn get_check_status(&self, repo: &RepoRef, pr_number: u64)
-> anyhow::Result<CheckStatus>;
async fn request_review(
&self,
repo: &RepoRef,
pr_number: u64,
reviewers: &[String],
) -> anyhow::Result<()>;
}
pub struct GitHubForge {
token: String,
client: reqwest::Client,
api_base: String,
}
impl GitHubForge {
pub fn new(token: String) -> Self {
Self {
token,
client: reqwest::Client::new(),
api_base: "https://api.github.com".to_string(),
}
}
pub fn with_api_base(mut self, base: String) -> Self {
self.api_base = base;
self
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.token)
}
}
#[async_trait]
impl GitForge for GitHubForge {
fn name(&self) -> &str {
"github"
}
async fn get_issue(&self, repo: &RepoRef, issue_ref: &str) -> anyhow::Result<Issue> {
let number: u64 = issue_ref
.trim_start_matches('#')
.parse()
.map_err(|_| anyhow::anyhow!("Invalid issue reference: {issue_ref}"))?;
let url = format!(
"{}/repos/{}/{}/issues/{number}",
self.api_base, repo.owner, repo.name
);
let resp: serde_json::Value = self
.client
.get(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(Issue {
id: resp["id"].to_string(),
number,
title: resp["title"].as_str().unwrap_or("").to_string(),
body: resp["body"].as_str().unwrap_or("").to_string(),
labels: resp["labels"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|l| l["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
author: resp["user"]["login"].as_str().unwrap_or("").to_string(),
url: resp["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn create_pull_request(
&self,
repo: &RepoRef,
params: CreatePrParams,
) -> anyhow::Result<PullRequest> {
let url = format!("{}/repos/{}/{}/pulls", self.api_base, repo.owner, repo.name);
let body = serde_json::json!({
"title": params.title,
"body": params.body,
"head": params.head_branch,
"base": params.base_branch,
"draft": params.draft,
});
let resp: serde_json::Value = self
.client
.post(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(PullRequest {
id: resp["id"].to_string(),
number: resp["number"].as_u64().unwrap_or(0),
title: resp["title"].as_str().unwrap_or("").to_string(),
body: resp["body"].as_str().unwrap_or("").to_string(),
head_branch: params.head_branch,
base_branch: params.base_branch,
url: resp["html_url"].as_str().unwrap_or("").to_string(),
state: PrState::Open,
})
}
async fn add_comment(
&self,
repo: &RepoRef,
target_number: u64,
body: &str,
) -> anyhow::Result<()> {
let url = format!(
"{}/repos/{}/{}/issues/{target_number}/comments",
self.api_base, repo.owner, repo.name
);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.json(&serde_json::json!({ "body": body }))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn merge_pull_request(
&self,
repo: &RepoRef,
pr_number: u64,
method: MergeMethod,
) -> anyhow::Result<()> {
let url = format!(
"{}/repos/{}/{}/pulls/{pr_number}/merge",
self.api_base, repo.owner, repo.name
);
let merge_method = match method {
MergeMethod::Merge => "merge",
MergeMethod::Squash => "squash",
MergeMethod::Rebase => "rebase",
};
self.client
.put(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.json(&serde_json::json!({ "merge_method": merge_method }))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn get_check_status(
&self,
repo: &RepoRef,
pr_number: u64,
) -> anyhow::Result<CheckStatus> {
let pr_url = format!(
"{}/repos/{}/{}/pulls/{pr_number}",
self.api_base, repo.owner, repo.name
);
let pr_resp: serde_json::Value = self
.client
.get(&pr_url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.send()
.await?
.error_for_status()?
.json()
.await?;
let sha = pr_resp["head"]["sha"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No head SHA found"))?;
let status_url = format!(
"{}/repos/{}/{}/commits/{sha}/check-runs",
self.api_base, repo.owner, repo.name
);
let resp: serde_json::Value = self
.client
.get(&status_url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.send()
.await?
.error_for_status()?
.json()
.await?;
let checks: Vec<CheckRun> = resp["check_runs"]
.as_array()
.map(|arr| {
arr.iter()
.map(|c| CheckRun {
name: c["name"].as_str().unwrap_or("").to_string(),
status: c["status"].as_str().unwrap_or("").to_string(),
conclusion: c["conclusion"].as_str().map(|s| s.to_string()),
})
.collect()
})
.unwrap_or_default();
let state = if checks
.iter()
.all(|c| c.conclusion.as_deref() == Some("success"))
{
CheckState::Success
} else if checks
.iter()
.any(|c| c.conclusion.as_deref() == Some("failure"))
{
CheckState::Failure
} else if checks.iter().any(|c| c.status != "completed") {
CheckState::Pending
} else {
CheckState::Error
};
Ok(CheckStatus { state, checks })
}
async fn request_review(
&self,
repo: &RepoRef,
pr_number: u64,
reviewers: &[String],
) -> anyhow::Result<()> {
let url = format!(
"{}/repos/{}/{}/pulls/{pr_number}/requested_reviewers",
self.api_base, repo.owner, repo.name
);
self.client
.post(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "brainwires-autonomy")
.json(&serde_json::json!({ "reviewers": reviewers }))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_ref_full_name() {
let r = RepoRef {
owner: "nightness".to_string(),
name: "brainwires".to_string(),
};
assert_eq!(r.full_name(), "nightness/brainwires");
}
#[test]
fn repo_ref_full_name_with_org() {
let r = RepoRef {
owner: "my-org".to_string(),
name: "my-repo".to_string(),
};
assert_eq!(r.full_name(), "my-org/my-repo");
}
}