use anyhow::{anyhow, Context, Result};
use reqwest::Client;
use serde::Deserialize;
use crate::cache::{Project, Repo, StatusOption};
pub struct GitHubClient {
client: Client,
token: String,
}
#[derive(Debug)]
pub struct CreatedIssue {
pub number: u64,
pub html_url: String,
pub node_id: String,
}
pub struct StatusField {
pub field_id: String,
pub options: Vec<StatusOption>,
}
impl GitHubClient {
pub fn new(token: &str) -> Self {
let client = Client::builder()
.user_agent("pike")
.build()
.expect("Failed to create HTTP client");
Self {
client,
token: token.to_string(),
}
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.token)
}
pub async fn list_org_repos(&self, org: &str) -> Result<Vec<Repo>> {
let mut all_repos: Vec<Repo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}"
);
let resp = self
.client
.get(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("Failed to fetch org repos")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("GitHub API error ({status}): {body}"));
}
let repos_raw: Vec<RepoResponse> = resp
.json()
.await
.context("Failed to parse repo list response")?;
let count = repos_raw.len();
all_repos.extend(repos_raw.into_iter().map(|r| Repo {
name: r.name,
full_name: r.full_name,
}));
if count < 100 {
break;
}
page += 1;
}
Ok(all_repos)
}
pub async fn create_issue(
&self,
owner_repo: &str,
title: &str,
body: &str,
) -> Result<CreatedIssue> {
let url = format!("https://api.github.com/repos/{owner_repo}/issues");
let body_json = serde_json::json!({
"title": title,
"body": body,
});
let resp = self
.client
.post(&url)
.header("Authorization", self.auth_header())
.header("Accept", "application/vnd.github+json")
.json(&body_json)
.send()
.await
.context("Failed to create issue")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("GitHub API error ({status}): {body}"));
}
let issue: CreateIssueResponse = resp
.json()
.await
.context("Failed to parse create issue response")?;
Ok(CreatedIssue {
number: issue.number,
html_url: issue.html_url,
node_id: issue.node_id,
})
}
pub async fn list_org_projects(&self, org: &str) -> Result<Vec<Project>> {
let query = r#"
query($login: String!) {
organization(login: $login) {
projectsV2(first: 100) {
nodes {
id
title
}
}
}
}
"#;
let resp = self
.graphql_request(query, serde_json::json!({ "login": org }))
.await?;
let org_data = resp
.get("organization")
.ok_or_else(|| anyhow!("No organization found in response"))?;
let nodes = org_data
.get("projectsV2")
.and_then(|p| p.get("nodes"))
.and_then(|n| n.as_array())
.ok_or_else(|| anyhow!("No projects found in response"))?;
let mut projects = Vec::new();
for node in nodes {
let id = node["id"].as_str().unwrap_or("").to_string();
let title = node["title"].as_str().unwrap_or("").to_string();
if !id.is_empty() {
projects.push(Project {
id,
title,
status_field_id: String::new(),
status_options: Vec::new(),
});
}
}
for project in &mut projects {
match self.get_project_status_field(&project.id).await {
Ok(status) => {
project.status_field_id = status.field_id;
project.status_options = status.options;
}
Err(_) => continue,
}
}
Ok(projects)
}
pub async fn get_project_status_field(&self, project_id: &str) -> Result<StatusField> {
let query = r#"
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
"#;
let resp = self
.graphql_request(query, serde_json::json!({ "id": project_id }))
.await?;
let node = resp
.get("node")
.ok_or_else(|| anyhow!("No node found in response"))?;
let fields = node
.get("fields")
.and_then(|f| f.get("nodes"))
.and_then(|n| n.as_array())
.ok_or_else(|| anyhow!("No fields found in project"))?;
for field in fields {
let name = field["name"].as_str().unwrap_or("");
if name.eq_ignore_ascii_case("status") {
let field_id = field["id"].as_str().unwrap_or("").to_string();
let options = field
.get("options")
.and_then(|o| o.as_array())
.map(|arr| {
arr.iter()
.filter_map(|opt| {
let id = opt["id"].as_str()?;
let name = opt["name"].as_str()?;
Some(StatusOption {
id: id.to_string(),
name: name.to_string(),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
return Ok(StatusField { field_id, options });
}
}
Ok(StatusField {
field_id: String::new(),
options: Vec::new(),
})
}
pub async fn add_issue_to_project(
&self,
project_id: &str,
issue_node_id: &str,
) -> Result<String> {
let query = r#"
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item {
id
}
}
}
"#;
let resp = self
.graphql_request(
query,
serde_json::json!({
"projectId": project_id,
"contentId": issue_node_id,
}),
)
.await?;
let item_id = resp
.get("addProjectV2ItemById")
.and_then(|r| r.get("item"))
.and_then(|i| i.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| anyhow!("Failed to get item ID from response"))?
.to_string();
Ok(item_id)
}
pub async fn set_issue_status(
&self,
project_id: &str,
item_id: &str,
field_id: &str,
option_id: &str,
) -> Result<()> {
let query = r#"
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId,
itemId: $itemId,
fieldId: $fieldId,
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
"#;
self.graphql_request(
query,
serde_json::json!({
"projectId": project_id,
"itemId": item_id,
"fieldId": field_id,
"optionId": option_id,
}),
)
.await?;
Ok(())
}
async fn graphql_request(
&self,
query: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"query": query,
"variables": variables,
});
let resp = self
.client
.post("https://api.github.com/graphql")
.header("Authorization", self.auth_header())
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("Failed to send GraphQL request")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("GitHub GraphQL API error ({status}): {text}"));
}
let full: serde_json::Value = resp
.json()
.await
.context("Failed to parse GraphQL response")?;
if let Some(errors) = full.get("errors") {
return Err(anyhow!("GitHub GraphQL errors: {errors}"));
}
full.get("data")
.cloned()
.ok_or_else(|| anyhow!("No data in GraphQL response"))
}
}
#[derive(Deserialize)]
struct RepoResponse {
name: String,
full_name: String,
}
#[derive(Deserialize)]
struct CreateIssueResponse {
number: u64,
html_url: String,
node_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_response_deserialize() {
let json = r#"{"name": "pike", "full_name": "anomalyco/pike", "private": false}"#;
let repo: RepoResponse = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "pike");
assert_eq!(repo.full_name, "anomalyco/pike");
}
#[test]
fn create_issue_response_deserialize() {
let json = r#"{
"number": 42,
"html_url": "https://github.com/anomalyco/pike/issues/42",
"node_id": "I_kwDO123456",
"title": "Bug"
}"#;
let issue: CreateIssueResponse = serde_json::from_str(json).unwrap();
assert_eq!(issue.number, 42);
assert_eq!(issue.html_url, "https://github.com/anomalyco/pike/issues/42");
assert_eq!(issue.node_id, "I_kwDO123456");
}
#[test]
fn graphql_request_body_structure() {
let query = "query { viewer { login } }";
let variables = serde_json::json!({ "login": "test" });
let body = serde_json::json!({
"query": query,
"variables": variables,
});
assert_eq!(body["query"], query);
assert_eq!(body["variables"]["login"], "test");
}
#[test]
fn parse_graphql_projects_response() {
let data = serde_json::json!({
"organization": {
"projectsV2": {
"nodes": [
{ "id": "PVT_1", "title": "Roadmap" },
{ "id": "PVT_2", "title": "Backlog" }
]
}
}
});
let nodes = data["organization"]["projectsV2"]["nodes"].as_array().unwrap();
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0]["id"], "PVT_1");
assert_eq!(nodes[1]["title"], "Backlog");
}
#[test]
fn parse_graphql_status_field_response() {
let data = serde_json::json!({
"node": {
"fields": {
"nodes": [
{
"id": "PVTFL_status",
"name": "Status",
"options": [
{ "id": "opt1", "name": "Todo" },
{ "id": "opt2", "name": "Done" }
]
},
{
"id": "PVTFL_priority",
"name": "Priority",
"options": [
{ "id": "opt3", "name": "High" }
]
}
]
}
}
});
let fields = data["node"]["fields"]["nodes"].as_array().unwrap();
let status_field = fields
.iter()
.find(|f| f["name"].as_str().unwrap_or("").eq_ignore_ascii_case("status"))
.unwrap();
assert_eq!(status_field["id"], "PVTFL_status");
let options = status_field["options"].as_array().unwrap();
assert_eq!(options.len(), 2);
assert_eq!(options[0]["name"], "Todo");
}
#[test]
fn created_issue_fields() {
let issue = CreatedIssue {
number: 7,
html_url: "https://github.com/org/repo/issues/7".to_string(),
node_id: "node_123".to_string(),
};
assert_eq!(issue.number, 7);
assert_eq!(issue.html_url, "https://github.com/org/repo/issues/7");
assert_eq!(issue.node_id, "node_123");
}
}