Skip to main content

ytil_gh/
pr.rs

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