use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde::Deserialize;
use serde_json::{Value as JsonValue, json};
use super::error::{GitHubApiError, GitHubError, GitHubResult};
use super::source::{enc, encode_repo_path, manifest_workspace_path, workspace_git_source};
use super::{GITHUB_API, GITHUB_USER_AGENT};
#[derive(Clone, Debug, Deserialize)]
pub struct GitHubUser {
pub id: i64,
pub login: String,
pub name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GitHubRepo {
pub name: String,
pub owner: GitHubRepoOwner,
pub default_branch: String,
pub permissions: Option<GitHubRepoPermissions>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GitHubRepoOwner {
pub login: String,
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
pub struct GitHubRepoPermissions {
#[serde(default)]
pub admin: bool,
#[serde(default)]
pub push: bool,
}
#[derive(Clone, Debug)]
pub struct DiscoveredWorkspace {
pub path: String,
pub git_ref: String,
pub source: String,
}
#[derive(Clone, Debug)]
pub struct GitHubContentFile {
pub sha: String,
pub content: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GitHubPullRequest {
pub html_url: String,
pub number: i64,
pub state: Option<String>,
pub merged_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GitHubTreeEntry {
pub path: String,
#[serde(rename = "type")]
pub entry_type: String,
}
#[derive(Clone, Debug)]
pub struct RefComparison {
pub ahead_by: i64,
pub files: Vec<String>,
}
#[derive(Clone)]
pub struct GitHubClient {
http: reqwest::Client,
}
impl GitHubClient {
pub fn new() -> Self {
Self {
http: reqwest::Client::new(),
}
}
pub async fn viewer(&self, token: &str) -> GitHubResult<GitHubUser> {
self.get(token, "/user").await
}
pub async fn repo(&self, token: &str, owner: &str, name: &str) -> GitHubResult<GitHubRepo> {
self.get(token, &format!("/repos/{}/{}", enc(owner), enc(name)))
.await
}
pub async fn assert_repo_write_access(
&self,
token: &str,
owner: &str,
name: &str,
) -> GitHubResult<()> {
let repo = self.repo(token, owner, name).await?;
if let Some(permissions) = repo.permissions
&& !permissions.push
&& !permissions.admin
{
return Err(GitHubError::Other(format!(
"Your GitHub credential can read {owner}/{name}, but cannot push to it. Grant \
repository write access before editing this workspace."
)));
}
Ok(())
}
pub async fn discover_workspaces(
&self,
token: &str,
owner: &str,
name: &str,
git_ref: &str,
) -> GitHubResult<Vec<DiscoveredWorkspace>> {
let tree = self.tree(token, owner, name, git_ref).await?;
let mut workspaces: Vec<DiscoveredWorkspace> = tree
.into_iter()
.filter(|entry| {
entry.entry_type == "blob" && entry.path.ends_with("rototo-workspace.toml")
})
.map(|entry| {
let path = manifest_workspace_path(&entry.path);
DiscoveredWorkspace {
source: workspace_git_source(owner, name, git_ref, &path),
path,
git_ref: git_ref.to_owned(),
}
})
.collect();
workspaces.sort_by(|left, right| left.path.cmp(&right.path));
Ok(workspaces)
}
pub async fn branch_head_sha(
&self,
token: &str,
owner: &str,
name: &str,
branch: &str,
) -> GitHubResult<String> {
#[derive(Deserialize)]
struct RefResponse {
object: RefObject,
}
#[derive(Deserialize)]
struct RefObject {
sha: String,
#[serde(rename = "type")]
object_type: String,
}
let reference: RefResponse = self
.get(
token,
&format!(
"/repos/{}/{}/git/ref/{}",
enc(owner),
enc(name),
enc(&format!("heads/{branch}"))
),
)
.await?;
if reference.object.object_type != "commit" {
return Err(GitHubError::Other(format!(
"GitHub ref {branch} does not point to a commit"
)));
}
Ok(reference.object.sha)
}
pub async fn list_branches(
&self,
token: &str,
owner: &str,
name: &str,
) -> GitHubResult<Vec<String>> {
#[derive(Deserialize)]
struct Branch {
name: String,
}
let branches: Vec<Branch> = self
.get(
token,
&format!("/repos/{}/{}/branches?per_page=100", enc(owner), enc(name)),
)
.await?;
Ok(branches.into_iter().map(|branch| branch.name).collect())
}
pub async fn compare_refs(
&self,
token: &str,
owner: &str,
name: &str,
base: &str,
head: &str,
) -> GitHubResult<RefComparison> {
#[derive(Deserialize)]
struct Comparison {
ahead_by: i64,
#[serde(default)]
files: Vec<ComparisonFile>,
}
#[derive(Deserialize)]
struct ComparisonFile {
filename: String,
}
let comparison: Comparison = self
.get(
token,
&format!(
"/repos/{}/{}/compare/{}...{}",
enc(owner),
enc(name),
enc(base),
enc(head)
),
)
.await?;
Ok(RefComparison {
ahead_by: comparison.ahead_by,
files: comparison
.files
.into_iter()
.map(|file| file.filename)
.collect(),
})
}
pub async fn create_branch(
&self,
token: &str,
owner: &str,
name: &str,
branch: &str,
sha: &str,
) -> GitHubResult<()> {
let _: JsonValue = self
.send(
token,
reqwest::Method::POST,
&format!("/repos/{}/{}/git/refs", enc(owner), enc(name)),
Some(json!({ "ref": format!("refs/heads/{branch}"), "sha": sha })),
)
.await?;
Ok(())
}
pub async fn rename_branch(
&self,
token: &str,
owner: &str,
name: &str,
branch: &str,
new_name: &str,
) -> GitHubResult<String> {
#[derive(Deserialize)]
struct Renamed {
name: String,
}
let renamed: Renamed = self
.send(
token,
reqwest::Method::POST,
&format!(
"/repos/{}/{}/branches/{}/rename",
enc(owner),
enc(name),
enc(branch)
),
Some(json!({ "new_name": new_name })),
)
.await?;
Ok(renamed.name)
}
pub async fn file(
&self,
token: &str,
owner: &str,
name: &str,
path: &str,
git_ref: &str,
) -> GitHubResult<GitHubContentFile> {
#[derive(Deserialize)]
struct Content {
#[serde(rename = "type")]
content_type: String,
sha: String,
encoding: Option<String>,
content: Option<String>,
}
let file: Content = self
.get(
token,
&format!(
"/repos/{}/{}/contents/{}?ref={}",
enc(owner),
enc(name),
encode_repo_path(path),
enc(git_ref)
),
)
.await?;
let (Some(encoding), Some(content)) = (file.encoding, file.content) else {
return Err(GitHubError::Other(format!(
"GitHub path is not a readable file: {path}"
)));
};
if file.content_type != "file" || encoding != "base64" {
return Err(GitHubError::Other(format!(
"GitHub path is not a readable file: {path}"
)));
}
let bytes = BASE64
.decode(content.replace('\n', ""))
.map_err(GitHubError::other)?;
Ok(GitHubContentFile {
sha: file.sha,
content: String::from_utf8(bytes).map_err(GitHubError::other)?,
})
}
#[expect(clippy::too_many_arguments)]
pub async fn update_file(
&self,
token: &str,
owner: &str,
name: &str,
path: &str,
branch: &str,
sha: &str,
content: &str,
message: &str,
) -> GitHubResult<()> {
let _: JsonValue = self
.send(
token,
reqwest::Method::PUT,
&format!(
"/repos/{}/{}/contents/{}",
enc(owner),
enc(name),
encode_repo_path(path)
),
Some(json!({
"message": message,
"content": BASE64.encode(content.as_bytes()),
"sha": sha,
"branch": branch,
})),
)
.await?;
Ok(())
}
#[expect(clippy::too_many_arguments)]
pub async fn create_file(
&self,
token: &str,
owner: &str,
name: &str,
path: &str,
branch: &str,
content: &str,
message: &str,
) -> GitHubResult<()> {
let _: JsonValue = self
.send(
token,
reqwest::Method::PUT,
&format!(
"/repos/{}/{}/contents/{}",
enc(owner),
enc(name),
encode_repo_path(path)
),
Some(json!({
"message": message,
"content": BASE64.encode(content.as_bytes()),
"branch": branch,
})),
)
.await?;
Ok(())
}
#[expect(clippy::too_many_arguments)]
pub async fn delete_file(
&self,
token: &str,
owner: &str,
name: &str,
path: &str,
branch: &str,
sha: &str,
message: &str,
) -> GitHubResult<()> {
let _: JsonValue = self
.send(
token,
reqwest::Method::DELETE,
&format!(
"/repos/{}/{}/contents/{}",
enc(owner),
enc(name),
encode_repo_path(path)
),
Some(json!({ "message": message, "sha": sha, "branch": branch })),
)
.await?;
Ok(())
}
pub async fn tree(
&self,
token: &str,
owner: &str,
name: &str,
git_ref: &str,
) -> GitHubResult<Vec<GitHubTreeEntry>> {
#[derive(Deserialize)]
struct TreeResponse {
truncated: bool,
tree: Vec<GitHubTreeEntry>,
}
let tree: TreeResponse = self
.get(
token,
&format!(
"/repos/{}/{}/git/trees/{}?recursive=1",
enc(owner),
enc(name),
enc(git_ref)
),
)
.await?;
if tree.truncated {
return Err(GitHubError::Other(
"GitHub tree response was truncated".to_owned(),
));
}
Ok(tree.tree)
}
#[expect(clippy::too_many_arguments)]
pub async fn create_pull_request(
&self,
token: &str,
owner: &str,
name: &str,
title: &str,
body: &str,
head: &str,
base: &str,
) -> GitHubResult<GitHubPullRequest> {
self.send(
token,
reqwest::Method::POST,
&format!("/repos/{}/{}/pulls", enc(owner), enc(name)),
Some(json!({
"title": title,
"body": body,
"head": head,
"base": base,
"maintainer_can_modify": true,
})),
)
.await
}
pub async fn pull_request(
&self,
token: &str,
owner: &str,
name: &str,
number: i64,
) -> GitHubResult<GitHubPullRequest> {
self.get(
token,
&format!("/repos/{}/{}/pulls/{number}", enc(owner), enc(name)),
)
.await
}
async fn get<T: serde::de::DeserializeOwned>(
&self,
token: &str,
path: &str,
) -> GitHubResult<T> {
self.send(token, reqwest::Method::GET, path, None).await
}
async fn send<T: serde::de::DeserializeOwned>(
&self,
token: &str,
method: reqwest::Method,
path: &str,
body: Option<JsonValue>,
) -> GitHubResult<T> {
let started = std::time::Instant::now();
let method_label = method.as_str().to_owned();
let path_pattern = github_path_pattern(path);
tracing::debug!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
"console outbound GitHub REST call started"
);
let mut request = self
.http
.request(method, format!("{GITHUB_API}{path}"))
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("User-Agent", GITHUB_USER_AGENT)
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(body) = body {
request = request.json(&body);
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
tracing::warn!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub REST call failed before response"
);
return Err(GitHubError::other(err));
}
};
let status = response.status();
let text = match response.text().await {
Ok(text) => text,
Err(err) => {
tracing::warn!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
status = status.as_u16(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub REST response read failed"
);
return Err(GitHubError::other(err));
}
};
if status.is_success() {
tracing::info!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub REST call completed"
);
} else {
tracing::warn!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub REST call returned error status"
);
}
if !status.is_success() {
return Err(GitHubError::Api(GitHubApiError {
status: status.as_u16(),
response_text: text,
}));
}
serde_json::from_str(&text).map_err(|err| {
tracing::warn!(
operation = "github.rest",
method = %method_label,
path = %path_pattern,
status = status.as_u16(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub REST response decode failed"
);
GitHubError::other(err)
})
}
}
fn github_path_pattern(path: &str) -> String {
path.split('/')
.map(|segment| {
if segment.chars().all(|ch| ch.is_ascii_digit()) {
":number"
} else if segment.len() >= 32 && segment.chars().all(|ch| ch.is_ascii_hexdigit()) {
":sha"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/")
}