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/// A GitHub issue.
13#[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/// A label on a GitHub issue (gh CLI returns objects with a `name` field).
24#[derive(Debug, Clone, Deserialize)]
25pub struct IssueLabel {
26    pub name: String,
27}
28
29/// Client for GitHub operations via the `gh` CLI.
30pub 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
52/// Transition an issue from one label to another in a single gh call.
53pub 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
62/// Post a comment, logging errors instead of propagating them.
63///
64/// Comment failures should never crash the pipeline.
65pub 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        // Should not panic or return error
111        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}