use crate::prelude::*;
use std::collections::BTreeMap;
fn github_status_error(action: &str, status: reqwest::StatusCode) -> anyhow::Error {
anyhow::anyhow!(
"{} failed with GitHub API status {}",
action,
status.as_u16()
)
}
fn require_success(action: &str, response: reqwest::Response) -> Result<reqwest::Response> {
let status = response.status();
if status.is_success() {
Ok(response)
} else {
Err(github_status_error(action, status))
}
}
#[derive(Clone)]
pub struct GitHubClient {
client: reqwest::Client,
token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubUser {
pub login: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubLabel {
pub name: String,
pub color: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubIssue {
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub user: GitHubUser,
pub labels: Vec<GitHubLabel>,
pub comments: i64,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
pub html_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubComment {
pub id: i64,
pub body: String,
pub user: GitHubUser,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubGistFile {
pub filename: Option<String>,
#[serde(rename = "type")]
pub mime_type: Option<String>,
pub language: Option<String>,
pub raw_url: Option<String>,
pub size: Option<u64>,
pub truncated: Option<bool>,
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubGist {
pub id: String,
pub description: Option<String>,
pub public: bool,
pub html_url: String,
pub created_at: String,
pub updated_at: String,
pub owner: Option<GitHubUser>,
pub files: BTreeMap<String, GitHubGistFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GistFileInput {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGistRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub public: bool,
pub files: BTreeMap<String, GistFileInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateGistRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub files: BTreeMap<String, GistFileInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateIssueRequest {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateIssueRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateCommentRequest {
pub body: String,
}
impl GitHubClient {
pub fn new(token: &str) -> Self {
let client = reqwest::Client::builder()
.user_agent("wsb/1.0")
.no_proxy()
.build()
.expect("failed to build reqwest client");
Self {
client,
token: token.to_string(),
}
}
pub async fn validate_token(&self) -> Result<String> {
let response = require_success(
"GitHub token validation",
self.client
.get("https://api.github.com/user")
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?;
let resp: GitHubUser = response
.json()
.await
.context("failed to parse GitHub user response")?;
Ok(resp.login)
}
pub async fn list_issues(
&self,
owner: &str,
repo: &str,
state: &str,
) -> Result<Vec<GitHubIssue>> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
owner, repo, state
);
let response = require_success(
&format!("GitHub API error listing issues for {}/{}", owner, repo),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
)?;
let resp: Vec<GitHubIssue> = response
.json()
.await
.context("failed to parse issues response")?;
Ok(resp
.into_iter()
.filter(|i| !i.html_url.contains("/pull/"))
.collect())
}
pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}",
owner, repo, number
);
let issue: GitHubIssue = require_success(
&format!("failed to get issue {}/{}#{}", owner, repo, number),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn create_issue(
&self,
owner: &str,
repo: &str,
req: &CreateIssueRequest,
) -> Result<GitHubIssue> {
let url = format!("https://api.github.com/repos/{}/{}/issues", owner, repo);
let issue: GitHubIssue = require_success(
&format!("failed to create issue in {}/{}", owner, repo),
self.client
.post(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn update_issue(
&self,
owner: &str,
repo: &str,
number: i64,
req: &UpdateIssueRequest,
) -> Result<GitHubIssue> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}",
owner, repo, number
);
let issue: GitHubIssue = require_success(
&format!("failed to update issue {}/{}#{}", owner, repo, number),
self.client
.patch(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn list_comments(
&self,
owner: &str,
repo: &str,
number: i64,
) -> Result<Vec<GitHubComment>> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}/comments?per_page=100",
owner, repo, number
);
let comments: Vec<GitHubComment> = require_success(
&format!("failed to list comments for {}/{}#{}", owner, repo, number),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await?;
Ok(comments)
}
pub async fn add_comment(
&self,
owner: &str,
repo: &str,
number: i64,
body: &str,
) -> Result<GitHubComment> {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/{}/comments",
owner, repo, number
);
let comment: GitHubComment = require_success(
&format!("failed to add comment to {}/{}#{}", owner, repo, number),
self.client
.post(&url)
.bearer_auth(&self.token)
.json(&CreateCommentRequest {
body: body.to_string(),
})
.send()
.await?,
)?
.json()
.await?;
Ok(comment)
}
pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
let response = require_success(
"failed to list gists",
self.client
.get("https://api.github.com/gists?per_page=100")
.bearer_auth(&self.token)
.send()
.await?,
)?;
let gists: Vec<GitHubGist> = response
.json()
.await
.context("failed to parse gists response")?;
Ok(gists)
}
pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
let url = format!("https://api.github.com/gists/{}", gist_id);
let gist: GitHubGist = require_success(
&format!("failed to get gist {}", gist_id),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await
.context("failed to parse gist response")?;
Ok(gist)
}
pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
let gist: GitHubGist = require_success(
"failed to create gist",
self.client
.post("https://api.github.com/gists")
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await
.context("failed to parse create gist response")?;
Ok(gist)
}
pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
let url = format!("https://api.github.com/gists/{}", gist_id);
let gist: GitHubGist = require_success(
&format!("failed to update gist {}", gist_id),
self.client
.patch(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await
.context("failed to parse update gist response")?;
Ok(gist)
}
}
pub fn extract_github_repos(
submodules: &[crate::fleet::config::Submodule],
) -> Vec<(String, String)> {
let mut repos = Vec::new();
for sub in submodules {
if let Some((owner, repo)) = parse_github_url(&sub.url) {
repos.push((owner, repo));
}
}
repos
}
pub fn parse_github_url(url: &str) -> Option<(String, String)> {
let url = url.trim();
if let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
return parse_owner_repo(rest);
}
if let Some(rest) = url.strip_prefix("git@github.com:") {
return parse_owner_repo(rest);
}
None
}
fn parse_owner_repo(path: &str) -> Option<(String, String)> {
let path = path.trim_end_matches(".git").trim_end_matches('/');
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url_https() {
assert_eq!(
parse_github_url("https://github.com/wesm/agentsview"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_https_with_git_suffix() {
assert_eq!(
parse_github_url("https://github.com/wesm/agentsview.git"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_ssh() {
assert_eq!(
parse_github_url("git@github.com:wesm/agentsview.git"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_non_github() {
assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
}
#[test]
fn test_parse_github_url_invalid() {
assert_eq!(parse_github_url("not a url"), None);
}
}