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#[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#[derive(Debug, Deserialize)]
25pub struct PullRequestAuthor {
26 pub login: String,
27 pub is_bot: bool,
28}
29
30#[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
45pub 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
81pub 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
100pub 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
112pub 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
124pub 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
143pub 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}