use anyhow::{Context, Result};
use serde::Deserialize;
use shared::{GitAccount, GitBranch, GitRepo};
#[derive(Debug, Clone)]
pub struct OAuthTokens {
pub access_token: String,
pub refresh_token: Option<String>,
}
#[derive(Deserialize)]
struct TokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
#[derive(Deserialize)]
struct UserResponse {
login: String,
#[serde(default)]
avatar_url: Option<String>,
}
#[derive(Deserialize)]
struct RepoResponse {
full_name: String,
clone_url: String,
#[serde(default)]
default_branch: String,
#[serde(default)]
private: bool,
}
#[derive(Deserialize)]
struct BranchResponse {
name: String,
}
fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("rustploy")
.build()
.context("falha ao construir HTTP client")
}
fn trim(base_url: &str) -> &str {
base_url.trim_end_matches('/')
}
fn is_dotcom(base_url: &str) -> bool {
let b = trim(base_url);
b.is_empty() || b == "https://github.com" || b == "http://github.com"
}
pub fn web_base(base_url: &str) -> String {
if is_dotcom(base_url) {
"https://github.com".to_string()
} else {
trim(base_url).to_string()
}
}
fn api_base(base_url: &str) -> String {
if is_dotcom(base_url) {
"https://api.github.com".to_string()
} else {
format!("{}/api/v3", trim(base_url))
}
}
pub fn authorize_url(
base_url: &str,
client_id_enc: &str,
redirect_uri_enc: &str,
state_enc: &str,
) -> String {
format!(
"{}/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo&state={}",
web_base(base_url),
client_id_enc,
redirect_uri_enc,
state_enc,
)
}
pub async fn exchange_code(
base_url: &str,
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: &str,
) -> Result<OAuthTokens> {
let url = format!("{}/login/oauth/access_token", web_base(base_url));
let resp = client()?
.post(&url)
.header("Accept", "application/json")
.json(&serde_json::json!({
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
}))
.send()
.await
.context("falha ao chamar token endpoint do GitHub")?;
let resp = error_for_status(resp).await?;
let t: TokenResponse = resp.json().await.context("token response inválido")?;
tokens_or_error(t)
}
pub async fn refresh(
base_url: &str,
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<OAuthTokens> {
let url = format!("{}/login/oauth/access_token", web_base(base_url));
let resp = client()?
.post(&url)
.header("Accept", "application/json")
.json(&serde_json::json!({
"grant_type": "refresh_token",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
}))
.send()
.await
.context("falha ao renovar token do GitHub")?;
let resp = error_for_status(resp).await?;
let mut t: TokenResponse = resp.json().await.context("refresh response inválido")?;
if t.refresh_token.is_none() {
t.refresh_token = Some(refresh_token.to_string());
}
tokens_or_error(t)
}
fn tokens_or_error(t: TokenResponse) -> Result<OAuthTokens> {
if let Some(err) = t.error {
let desc = t.error_description.unwrap_or_default();
anyhow::bail!("GitHub OAuth error {err}: {desc}");
}
let access_token = t.access_token.context("token response sem access_token")?;
Ok(OAuthTokens {
access_token,
refresh_token: t.refresh_token,
})
}
pub async fn current_user(base_url: &str, token: &str) -> Result<GitAccount> {
let url = format!("{}/user", api_base(base_url));
let resp = get(&url, token)
.await
.context("falha ao consultar usuário no GitHub")?;
let resp = error_for_status(resp).await?;
let u: UserResponse = resp.json().await.context("user response inválido")?;
Ok(GitAccount {
login: u.login,
avatar_url: u.avatar_url,
})
}
pub async fn list_repos(base_url: &str, token: &str) -> Result<Vec<GitRepo>> {
let api = api_base(base_url);
let mut out = Vec::new();
for page in 1..=20 {
let url = format!(
"{api}/user/repos?per_page=100&page={page}&affiliation=owner,collaborator,organization_member"
);
let resp = get(&url, token)
.await
.context("falha ao listar repositórios no GitHub")?;
let resp = error_for_status(resp).await?;
let repos: Vec<RepoResponse> = resp.json().await.context("repos response inválido")?;
let n = repos.len();
out.extend(repos.into_iter().map(|r| GitRepo {
full_name: r.full_name,
clone_url: r.clone_url,
default_branch: r.default_branch,
private: r.private,
}));
if n < 100 {
break;
}
}
Ok(out)
}
pub async fn list_branches(
base_url: &str,
token: &str,
repo_full_name: &str,
) -> Result<Vec<GitBranch>> {
let url = format!(
"{}/repos/{repo_full_name}/branches?per_page=100",
api_base(base_url)
);
let resp = get(&url, token)
.await
.context("falha ao listar branches no GitHub")?;
let resp = error_for_status(resp).await?;
let branches: Vec<BranchResponse> = resp.json().await.context("branches response inválido")?;
Ok(branches
.into_iter()
.map(|b| GitBranch { name: b.name })
.collect())
}
pub async fn ensure_redirect_uri(
_base_url: &str,
_token: &str,
_client_id: &str,
_redirect_uri: &str,
) -> Result<()> {
Ok(())
}
async fn get(url: &str, token: &str) -> Result<reqwest::Response> {
Ok(client()?
.get(url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await?)
}
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("GitHub respondeu {status}: {body}");
}