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, Copy, PartialEq, Eq)]
14pub enum PrState {
15 Open,
16 Merged,
17 Closed,
18}
19
20#[derive(Debug, Clone, Deserialize)]
22pub struct Issue {
23 pub number: u32,
24 pub title: String,
25 #[serde(default)]
26 pub body: String,
27 #[serde(default)]
28 pub labels: Vec<IssueLabel>,
29 #[serde(default)]
30 pub author: Option<IssueAuthor>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35pub struct IssueLabel {
36 pub name: String,
37}
38
39#[derive(Debug, Clone, Deserialize)]
41pub struct IssueAuthor {
42 pub login: String,
43}
44
45pub struct GhClient<R: CommandRunner> {
47 runner: R,
48 repo_dir: PathBuf,
49}
50
51impl<R: CommandRunner> GhClient<R> {
52 pub fn new(runner: R, repo_dir: &Path) -> Self {
53 Self { runner, repo_dir: repo_dir.to_path_buf() }
54 }
55
56 fn s(args: &[&str]) -> Vec<String> {
57 args.iter().map(|a| (*a).to_string()).collect()
58 }
59
60 fn check_output(output: &CommandOutput, operation: &str) -> Result<()> {
61 if !output.success {
62 anyhow::bail!("{operation} failed: {}", output.stderr.trim());
63 }
64 Ok(())
65 }
66}
67
68pub async fn transition_issue<R: CommandRunner>(
70 client: &GhClient<R>,
71 issue_number: u32,
72 from: &str,
73 to: &str,
74) -> Result<()> {
75 client.swap_labels(issue_number, from, to).await
76}
77
78pub async fn safe_comment<R: CommandRunner>(
82 client: &GhClient<R>,
83 pr_number: u32,
84 body: &str,
85 repo_dir: &Path,
86) {
87 if let Err(e) = client.comment_on_pr_in(pr_number, body, repo_dir).await {
88 tracing::warn!("failed to post comment on PR #{pr_number}: {e}");
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::process::{CommandOutput, MockCommandRunner};
96
97 fn mock_gh_success() -> MockCommandRunner {
98 let mut mock = MockCommandRunner::new();
99 mock.expect_run_gh().returning(|_, _| {
100 Box::pin(async {
101 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
102 })
103 });
104 mock
105 }
106
107 fn mock_gh_failure() -> MockCommandRunner {
108 let mut mock = MockCommandRunner::new();
109 mock.expect_run_gh().returning(|_, _| {
110 Box::pin(async {
111 Ok(CommandOutput {
112 stdout: String::new(),
113 stderr: "API error".to_string(),
114 success: false,
115 })
116 })
117 });
118 mock
119 }
120
121 #[tokio::test]
122 async fn transition_issue_removes_and_adds_labels() {
123 let client = GhClient::new(mock_gh_success(), std::path::Path::new("/tmp"));
124 let result = transition_issue(&client, 42, "o-ready", "o-cooking").await;
125 assert!(result.is_ok());
126 }
127
128 #[tokio::test]
129 async fn safe_comment_swallows_errors() {
130 let client = GhClient::new(mock_gh_failure(), std::path::Path::new("/tmp"));
131 safe_comment(&client, 42, "test comment", std::path::Path::new("/tmp")).await;
132 }
133
134 #[tokio::test]
135 async fn safe_comment_succeeds_on_success() {
136 let client = GhClient::new(mock_gh_success(), std::path::Path::new("/tmp"));
137 safe_comment(&client, 42, "test comment", std::path::Path::new("/tmp")).await;
138 }
139
140 #[tokio::test]
141 async fn safe_comment_uses_given_repo_dir() {
142 let mut mock = MockCommandRunner::new();
143 mock.expect_run_gh().returning(|_, dir| {
144 assert_eq!(dir, std::path::Path::new("/repos/target"));
145 Box::pin(async {
146 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
147 })
148 });
149 let client = GhClient::new(mock, std::path::Path::new("/repos/god"));
150 safe_comment(&client, 42, "test", std::path::Path::new("/repos/target")).await;
151 }
152
153 #[test]
154 fn check_output_returns_error_on_failure() {
155 let output = CommandOutput {
156 stdout: String::new(),
157 stderr: "not found".to_string(),
158 success: false,
159 };
160 let result = GhClient::<MockCommandRunner>::check_output(&output, "test op");
161 assert!(result.is_err());
162 assert!(result.unwrap_err().to_string().contains("not found"));
163 }
164
165 #[test]
166 fn check_output_ok_on_success() {
167 let output =
168 CommandOutput { stdout: "ok".to_string(), stderr: String::new(), success: true };
169 let result = GhClient::<MockCommandRunner>::check_output(&output, "test op");
170 assert!(result.is_ok());
171 }
172}