use anyhow::{Context, Result};
use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Serialize};
const GITHUB_API_BASE: &str = "https://api.github.com";
const USER_AGENT_VALUE: &str = "mx-sync/0.1";
pub struct RestClient {
client: Client,
token: String,
}
impl RestClient {
pub fn new(token: String) -> Result<Self> {
let client = Client::builder()
.default_headers(Self::default_headers(&token)?)
.build()
.context("Failed to create HTTP client")?;
Ok(Self { client, token })
}
fn default_headers(token: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token)).context("Invalid token format")?,
);
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
headers.insert(
"X-GitHub-Api-Version",
HeaderValue::from_static("2022-11-28"),
);
Ok(headers)
}
pub fn list_issues(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
let mut all_issues = Vec::new();
let mut page = 1;
loop {
let url = format!(
"{}/repos/{}/{}/issues?state={}&per_page=100&page={}",
GITHUB_API_BASE, owner, repo, state, page
);
let response: Vec<Issue> = self
.client
.get(&url)
.send()
.context("Failed to fetch issues")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse issues response")?;
if response.is_empty() {
break;
}
let count = response.len();
all_issues.extend(response);
if count < 100 {
break;
}
page += 1;
}
all_issues.retain(|issue| issue.pull_request.is_none());
Ok(all_issues)
}
pub fn get_issue(&self, owner: &str, repo: &str, number: u64) -> Result<Issue> {
let url = format!(
"{}/repos/{}/{}/issues/{}",
GITHUB_API_BASE, owner, repo, number
);
self.client
.get(&url)
.send()
.context("Failed to fetch issue")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse issue response")
}
pub fn create_issue(&self, owner: &str, repo: &str, req: &CreateIssueRequest) -> Result<Issue> {
let url = format!("{}/repos/{}/{}/issues", GITHUB_API_BASE, owner, repo);
self.client
.post(&url)
.json(req)
.send()
.context("Failed to create issue")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse create issue response")
}
pub fn update_issue(
&self,
owner: &str,
repo: &str,
number: u64,
req: &UpdateIssueRequest,
) -> Result<Issue> {
let url = format!(
"{}/repos/{}/{}/issues/{}",
GITHUB_API_BASE, owner, repo, number
);
self.client
.patch(&url)
.json(req)
.send()
.context("Failed to update issue")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse update issue response")
}
pub fn list_labels(&self, owner: &str, repo: &str) -> Result<Vec<Label>> {
let mut all_labels = Vec::new();
let mut page = 1;
loop {
let url = format!(
"{}/repos/{}/{}/labels?per_page=100&page={}",
GITHUB_API_BASE, owner, repo, page
);
let response: Vec<Label> = self
.client
.get(&url)
.send()
.context("Failed to fetch labels")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse labels response")?;
if response.is_empty() {
break;
}
let count = response.len();
all_labels.extend(response);
if count < 100 {
break;
}
page += 1;
}
Ok(all_labels)
}
pub fn create_label(&self, owner: &str, repo: &str, req: &CreateLabelRequest) -> Result<Label> {
let url = format!("{}/repos/{}/{}/labels", GITHUB_API_BASE, owner, repo);
self.client
.post(&url)
.json(req)
.send()
.context("Failed to create label")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse create label response")
}
pub fn update_label(
&self,
owner: &str,
repo: &str,
name: &str,
req: &UpdateLabelRequest,
) -> Result<Label> {
let url = format!(
"{}/repos/{}/{}/labels/{}",
GITHUB_API_BASE,
owner,
repo,
urlencoding::encode(name)
);
self.client
.patch(&url)
.json(req)
.send()
.context("Failed to update label")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse update label response")
}
pub fn list_issue_comments(
&self,
owner: &str,
repo: &str,
number: u64,
) -> Result<Vec<Comment>> {
let mut all_comments = Vec::new();
let mut page = 1;
loop {
let url = format!(
"{}/repos/{}/{}/issues/{}/comments?per_page=100&page={}",
GITHUB_API_BASE, owner, repo, number, page
);
let response: Vec<Comment> = self
.client
.get(&url)
.send()
.context("Failed to fetch comments")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse comments response")?;
if response.is_empty() {
break;
}
let count = response.len();
all_comments.extend(response);
if count < 100 {
break;
}
page += 1;
}
Ok(all_comments)
}
pub fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
url: &str,
body: &T,
) -> Result<R> {
self.client
.post(url)
.json(body)
.send()
.context("Failed to execute POST request")?
.error_for_status()
.context("GitHub API error")?
.json()
.context("Failed to parse JSON response")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Issue {
pub number: u64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub labels: Vec<LabelRef>,
pub assignees: Vec<UserRef>,
pub updated_at: String,
pub pull_request: Option<PullRequestRef>,
}
impl Issue {
pub fn label_names(&self) -> Vec<String> {
self.labels.iter().map(|l| l.name.clone()).collect()
}
pub fn assignee_logins(&self) -> Vec<String> {
self.assignees.iter().map(|a| a.login.clone()).collect()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LabelRef {
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserRef {
pub login: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PullRequestRef {
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Label {
pub name: String,
pub color: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Comment {
pub id: u64,
pub body: Option<String>,
pub user: UserRef,
pub created_at: String,
}
#[derive(Debug, Serialize)]
pub struct CreateIssueRequest {
pub title: String,
pub body: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub assignees: Vec<String>,
}
#[derive(Debug, Serialize)]
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 labels: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateLabelRequest {
pub name: String,
pub color: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UpdateLabelRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub new_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}