mod action;
mod issue;
use std::future::Future;
use std::pin::Pin;
use crate::error::GitHubError;
pub use action::{ActionChoice, ActionType, build_prompt, generate_choices};
pub use issue::{GitHubIssue, GitHubLabel, IssueState, fetch_issue, fetch_issue_list};
async fn run_command<T: serde::de::DeserializeOwned>(
cmd: &str,
args: &[&str],
context: &str,
) -> Result<T, GitHubError> {
use tokio::process::Command;
let output =
Command::new(cmd)
.args(args)
.output()
.await
.map_err(|e| GitHubError::CommandFailed {
command: context.to_string(),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitHubError::CommandFailed {
command: context.to_string(),
source: std::io::Error::other(stderr.to_string()),
});
}
serde_json::from_slice(&output.stdout).map_err(|e| GitHubError::ParseFailed {
context: context.to_string(),
message: e.to_string(),
})
}
pub trait GitHubClient: Send + Sync {
fn fetch_issues(
&self,
) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>>;
fn fetch_issue(
&self,
number: u32,
) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>>;
fn generate_choices(
&self,
issue: &GitHubIssue,
) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>>;
}
#[derive(Debug, Default, Clone)]
pub struct NativeGitHubClient;
impl GitHubClient for NativeGitHubClient {
fn fetch_issues(
&self,
) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>> {
Box::pin(fetch_issue_list())
}
fn fetch_issue(
&self,
number: u32,
) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>> {
Box::pin(fetch_issue(number))
}
fn generate_choices(
&self,
issue: &GitHubIssue,
) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>> {
let issue = issue.clone();
Box::pin(async move { generate_choices(&issue).await })
}
}
#[allow(clippy::unwrap_used)]
pub mod mock {
use super::{ActionChoice, Future, GitHubClient, GitHubError, GitHubIssue, Pin};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Default)]
pub struct MockGitHubClient {
issues: Arc<Mutex<Vec<GitHubIssue>>>,
choices: Arc<Mutex<Vec<ActionChoice>>>,
fetch_error: Arc<Mutex<Option<String>>>,
}
impl MockGitHubClient {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_issues(self, issues: Vec<GitHubIssue>) -> Self {
*self.issues.lock().unwrap() = issues;
self
}
#[must_use]
pub fn with_choices(self, choices: Vec<ActionChoice>) -> Self {
*self.choices.lock().unwrap() = choices;
self
}
#[must_use]
pub fn with_error(self, error: &str) -> Self {
*self.fetch_error.lock().unwrap() = Some(error.to_string());
self
}
}
impl GitHubClient for MockGitHubClient {
fn fetch_issues(
&self,
) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>>
{
let issues = self.issues.clone();
let error = self.fetch_error.clone();
Box::pin(async move {
if let Some(e) = error.lock().unwrap().take() {
return Err(GitHubError::ParseFailed {
context: "mock".to_string(),
message: e,
});
}
Ok(issues.lock().unwrap().clone())
})
}
fn fetch_issue(
&self,
number: u32,
) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>> {
let issues = self.issues.clone();
let error = self.fetch_error.clone();
Box::pin(async move {
if let Some(e) = error.lock().unwrap().take() {
return Err(GitHubError::ParseFailed {
context: "mock".to_string(),
message: e,
});
}
issues
.lock()
.unwrap()
.iter()
.find(|i| i.number == number)
.cloned()
.ok_or_else(|| GitHubError::ParseFailed {
context: format!("issue #{number}"),
message: "not found".to_string(),
})
})
}
fn generate_choices(
&self,
_issue: &GitHubIssue,
) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>>
{
let choices = self.choices.clone();
let error = self.fetch_error.clone();
Box::pin(async move {
if let Some(e) = error.lock().unwrap().take() {
return Err(GitHubError::ParseFailed {
context: "mock".to_string(),
message: e,
});
}
Ok(choices.lock().unwrap().clone())
})
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use mock::MockGitHubClient;
#[tokio::test]
async fn mock_client_fetch_issues() {
let client = MockGitHubClient::new().with_issues(vec![GitHubIssue {
number: 1,
title: "Test Issue".to_string(),
body: "Test body".to_string(),
labels: vec![],
state: IssueState::Open,
}]);
let issues = client.fetch_issues().await.unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].number, 1);
}
#[tokio::test]
async fn mock_client_fetch_issue() {
let client = MockGitHubClient::new().with_issues(vec![GitHubIssue {
number: 42,
title: "Issue 42".to_string(),
body: String::new(),
labels: vec![],
state: IssueState::Open,
}]);
let issue = client.fetch_issue(42).await.unwrap();
assert_eq!(issue.number, 42);
}
#[tokio::test]
async fn mock_client_fetch_issue_not_found() {
let client = MockGitHubClient::new();
let result = client.fetch_issue(999).await;
assert!(result.is_err());
}
#[tokio::test]
async fn mock_client_generate_choices() {
let client = MockGitHubClient::new().with_choices(vec![ActionChoice {
branch: "feat/test".to_string(),
action: ActionType::Implement,
prompt: "Test".to_string(),
}]);
let issue = GitHubIssue {
number: 1,
title: "Test".to_string(),
body: String::new(),
labels: vec![],
state: IssueState::Open,
};
let choices = client.generate_choices(&issue).await.unwrap();
assert_eq!(choices.len(), 1);
assert_eq!(choices[0].branch, "feat/test");
}
#[tokio::test]
async fn mock_client_error_propagation() {
let issue = GitHubIssue {
number: 1,
title: "T".to_string(),
body: String::new(),
labels: vec![],
state: IssueState::Open,
};
let c1 = MockGitHubClient::new().with_error("e1");
assert!(c1.fetch_issues().await.is_err());
let c2 = MockGitHubClient::new().with_error("e2");
assert!(c2.fetch_issue(1).await.is_err());
let c3 = MockGitHubClient::new().with_error("e3");
assert!(c3.generate_choices(&issue).await.is_err());
}
}