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#[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#[derive(Debug, Deserialize)]
26pub struct PullRequestAuthor {
27 pub login: String,
28 pub is_bot: bool,
29}
30
31#[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
46pub 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
82pub 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
101pub 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
113pub 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
125pub 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
144pub 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}