Skip to main content

oven_cli/github/
mod.rs

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/// The merge state of a pull request.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PrState {
15    Open,
16    Merged,
17    Closed,
18}
19
20/// A GitHub issue.
21#[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/// A label on a GitHub issue (gh CLI returns objects with a `name` field).
34#[derive(Debug, Clone, Deserialize)]
35pub struct IssueLabel {
36    pub name: String,
37}
38
39/// The author of a GitHub issue (gh CLI returns `{"login": "..."}` objects).
40#[derive(Debug, Clone, Deserialize)]
41pub struct IssueAuthor {
42    pub login: String,
43}
44
45/// Client for GitHub operations via the `gh` CLI.
46pub 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
68/// Transition an issue from one label to another in a single gh call.
69pub 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
78/// Post a comment on a PR in a specific repo, logging errors instead of propagating them.
79///
80/// Comment failures should never crash the pipeline.
81pub 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}