1pub mod issues;
2pub mod labels;
3pub mod prs;
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Result;
8use serde::Deserialize;
9
10use crate::process::{CommandOutput, CommandRunner};
11
12#[derive(Debug, Clone, Deserialize)]
14pub struct Issue {
15 pub number: u32,
16 pub title: String,
17 #[serde(default)]
18 pub body: String,
19 #[serde(default)]
20 pub labels: Vec<IssueLabel>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
25pub struct IssueLabel {
26 pub name: String,
27}
28
29pub struct GhClient<R: CommandRunner> {
31 runner: R,
32 repo_dir: PathBuf,
33}
34
35impl<R: CommandRunner> GhClient<R> {
36 pub fn new(runner: R, repo_dir: &Path) -> Self {
37 Self { runner, repo_dir: repo_dir.to_path_buf() }
38 }
39
40 fn s(args: &[&str]) -> Vec<String> {
41 args.iter().map(|a| (*a).to_string()).collect()
42 }
43
44 fn check_output(output: &CommandOutput, operation: &str) -> Result<()> {
45 if !output.success {
46 anyhow::bail!("{operation} failed: {}", output.stderr.trim());
47 }
48 Ok(())
49 }
50}
51
52pub async fn transition_issue<R: CommandRunner>(
54 client: &GhClient<R>,
55 issue_number: u32,
56 from: &str,
57 to: &str,
58) -> Result<()> {
59 client.swap_labels(issue_number, from, to).await
60}
61
62pub async fn safe_comment<R: CommandRunner>(client: &GhClient<R>, pr_number: u32, body: &str) {
66 if let Err(e) = client.comment_on_pr(pr_number, body).await {
67 tracing::warn!("failed to post comment on PR #{pr_number}: {e}");
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use crate::process::{CommandOutput, MockCommandRunner};
75
76 fn mock_gh_success() -> MockCommandRunner {
77 let mut mock = MockCommandRunner::new();
78 mock.expect_run_gh().returning(|_, _| {
79 Box::pin(async {
80 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
81 })
82 });
83 mock
84 }
85
86 fn mock_gh_failure() -> MockCommandRunner {
87 let mut mock = MockCommandRunner::new();
88 mock.expect_run_gh().returning(|_, _| {
89 Box::pin(async {
90 Ok(CommandOutput {
91 stdout: String::new(),
92 stderr: "API error".to_string(),
93 success: false,
94 })
95 })
96 });
97 mock
98 }
99
100 #[tokio::test]
101 async fn transition_issue_removes_and_adds_labels() {
102 let client = GhClient::new(mock_gh_success(), std::path::Path::new("/tmp"));
103 let result = transition_issue(&client, 42, "o-ready", "o-cooking").await;
104 assert!(result.is_ok());
105 }
106
107 #[tokio::test]
108 async fn safe_comment_swallows_errors() {
109 let client = GhClient::new(mock_gh_failure(), std::path::Path::new("/tmp"));
110 safe_comment(&client, 42, "test comment").await;
112 }
113
114 #[tokio::test]
115 async fn safe_comment_succeeds_on_success() {
116 let client = GhClient::new(mock_gh_success(), std::path::Path::new("/tmp"));
117 safe_comment(&client, 42, "test comment").await;
118 }
119
120 #[test]
121 fn check_output_returns_error_on_failure() {
122 let output = CommandOutput {
123 stdout: String::new(),
124 stderr: "not found".to_string(),
125 success: false,
126 };
127 let result = GhClient::<MockCommandRunner>::check_output(&output, "test op");
128 assert!(result.is_err());
129 assert!(result.unwrap_err().to_string().contains("not found"));
130 }
131
132 #[test]
133 fn check_output_ok_on_success() {
134 let output =
135 CommandOutput { stdout: "ok".to_string(), stderr: String::new(), success: true };
136 let result = GhClient::<MockCommandRunner>::check_output(&output, "test op");
137 assert!(result.is_ok());
138 }
139}