Skip to main content

ytil_gh/
pr.rs

1use std::process::Command;
2
3use chrono::DateTime;
4use chrono::Utc;
5use rootcause::bail;
6use serde::Deserialize;
7use strum::EnumIter;
8use strum::EnumString;
9pub use strum::IntoEnumIterator;
10use ytil_cmd::CmdExt;
11
12/// Pull request summary fetched via the `gh pr list` command.
13#[derive(Debug, Deserialize)]
14pub struct PullRequest {
15    pub number: usize,
16    pub title: String,
17    pub author: PullRequestAuthor,
18    #[serde(rename = "mergeStateStatus")]
19    pub merge_state: PullRequestMergeState,
20    #[serde(rename = "updatedAt")]
21    pub updated_at: DateTime<Utc>,
22}
23
24/// Author metadata for a pull request.
25#[derive(Debug, Deserialize)]
26pub struct PullRequestAuthor {
27    pub login: String,
28    pub is_bot: bool,
29}
30
31/// Merge state classification returned by GitHub's `mergeStateStatus` field.
32#[derive(Clone, Copy, Debug, Deserialize, EnumIter, EnumString, Eq, PartialEq)]
33#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
34pub enum PullRequestMergeState {
35    Behind,
36    Blocked,
37    Clean,
38    Dirty,
39    Draft,
40    HasHooks,
41    Unknown,
42    Unmergeable,
43    Unstable,
44}
45
46/// Fetch pull requests for a repository using `gh pr list`.
47///
48/// # Errors
49/// - Spawning or executing `gh pr list` fails.
50/// - Command exits non‑zero (handled inside [`ytil_cmd::CmdExt`]).
51/// - Output JSON cannot be deserialized.
52pub fn get(
53    repo: &str,
54    search: Option<&str>,
55    retain_fn: &dyn Fn(&PullRequest) -> bool,
56) -> rootcause::Result<Vec<PullRequest>> {
57    let mut args = vec![
58        "pr",
59        "list",
60        "--repo",
61        repo,
62        "--json",
63        "number,title,author,mergeStateStatus,updatedAt",
64    ];
65    if let Some(s) = search.filter(|s| !s.is_empty()) {
66        args.extend(["--search", s]);
67    }
68
69    let output = Command::new("gh").args(args).exec()?.stdout;
70
71    if output.is_empty() {
72        return Ok(Vec::new());
73    }
74
75    let mut prs: Vec<PullRequest> = serde_json::from_slice(&output)?;
76    prs.retain(|pr| retain_fn(pr));
77    prs.sort_unstable_by_key(|x| x.updated_at);
78
79    Ok(prs)
80}
81
82/// Merge a pull request using administrative squash semantics.
83///
84/// # Errors
85/// - Spawning or executing the `gh pr merge` command fails.
86/// - Command exits with non‑zero status.
87pub fn merge(pr_number: usize) -> rootcause::Result<()> {
88    Command::new("gh")
89        .args([
90            "pr",
91            "merge",
92            "--admin",
93            "--squash",
94            "--delete-branch",
95            &format!("{pr_number}"),
96        ])
97        .exec()?;
98    Ok(())
99}
100
101/// Approve a pull request via `gh pr review --approve`.
102///
103/// # Errors
104/// - Spawning or executing `gh pr review` fails.
105/// - Command exits with non‑zero status.
106pub fn approve(pr_number: usize) -> rootcause::Result<()> {
107    Command::new("gh")
108        .args(["pr", "review", &format!("{pr_number}"), "--approve"])
109        .exec()?;
110    Ok(())
111}
112
113/// Trigger Dependabot to rebase a pull request via `@dependabot rebase` comment.
114///
115/// # Errors
116/// - Spawning or executing `gh pr comment` fails.
117/// - Command exits with non‑zero status.
118pub fn dependabot_rebase(pr_number: usize) -> rootcause::Result<()> {
119    Command::new("gh")
120        .args(["pr", "comment", &format!("{pr_number}"), "--body", "@dependabot rebase"])
121        .exec()?;
122    Ok(())
123}
124
125/// Enable GitHub auto-merge for a pull request (squash strategy).
126///
127/// # Errors
128/// - Spawning or executing `gh pr merge` fails.
129/// - Command exits non-zero.
130pub fn enable_auto_merge(pr_number: usize) -> rootcause::Result<()> {
131    Command::new("gh")
132        .args([
133            "pr",
134            "merge",
135            &format!("{pr_number}"),
136            "--auto",
137            "--squash",
138            "--delete-branch",
139        ])
140        .exec()?;
141    Ok(())
142}
143
144/// Creates a GitHub pull request with the specified title.
145///
146/// # Errors
147/// - Title is empty or `gh pr create` fails.
148pub fn create(title: &str) -> rootcause::Result<String> {
149    if title.is_empty() {
150        bail!("error cannot create GitHub PR with empty title");
151    }
152    let output = Command::new("gh")
153        .args(["pr", "create", "--title", title, "--body", ""])
154        .exec()?;
155    ytil_cmd::extract_success_output(&output)
156}
157
158#[cfg(test)]
159mod tests {
160    use rstest::rstest;
161
162    use super::*;
163
164    #[test]
165    fn create_when_empty_title_returns_error() {
166        assert2::assert!(let Err(err) = create(""));
167        assert!(
168            err.to_string()
169                .contains("error cannot create GitHub PR with empty title")
170        );
171    }
172
173    #[rstest]
174    #[case("BEHIND", PullRequestMergeState::Behind)]
175    #[case("BLOCKED", PullRequestMergeState::Blocked)]
176    #[case("CLEAN", PullRequestMergeState::Clean)]
177    #[case("DIRTY", PullRequestMergeState::Dirty)]
178    #[case("DRAFT", PullRequestMergeState::Draft)]
179    #[case("HAS_HOOKS", PullRequestMergeState::HasHooks)]
180    #[case("UNKNOWN", PullRequestMergeState::Unknown)]
181    #[case("UNMERGEABLE", PullRequestMergeState::Unmergeable)]
182    #[case("UNSTABLE", PullRequestMergeState::Unstable)]
183    fn pull_request_merge_state_deserializes_all_variants(
184        #[case] status_str: &str,
185        #[case] expected: PullRequestMergeState,
186    ) {
187        let json = format!(
188            r#"{{"number":1,"title":"t","author":{{"login":"a","is_bot":false}},"mergeStateStatus":"{status_str}","updatedAt":"2024-01-01T00:00:00Z"}}"#
189        );
190        assert2::assert!(let Ok(pr) = serde_json::from_str::<PullRequest>(&json));
191        pretty_assertions::assert_eq!(pr.merge_state, expected);
192    }
193}