|
| 1 | +//! Wrapper around GitHub's `gh` command line tool. |
| 2 | +
|
| 3 | +use eyre::Context; |
| 4 | +use lib::core::effects::{Effects, OperationType}; |
| 5 | +use lib::git::SerializedNonZeroOid; |
| 6 | +use lib::try_exit_code; |
| 7 | +use lib::util::ExitCode; |
| 8 | +use lib::util::EyreExitOr; |
| 9 | +use serde::{Deserialize, Serialize}; |
| 10 | +use std::collections::HashMap; |
| 11 | +use std::fmt::{Debug, Write}; |
| 12 | +use std::path::PathBuf; |
| 13 | +use std::process::{Command, Stdio}; |
| 14 | +use std::sync::Arc; |
| 15 | +use tempfile::NamedTempFile; |
| 16 | +use tracing::{debug, instrument, warn}; |
| 17 | + |
| 18 | +/// Executable name for the GitHub CLI. |
| 19 | +pub const GH_EXE: &str = "gh"; |
| 20 | + |
| 21 | +#[allow(missing_docs)] |
| 22 | +#[derive(Clone, Debug, Deserialize, Serialize)] |
| 23 | +pub struct PullRequestInfo { |
| 24 | + #[serde(rename = "number")] |
| 25 | + pub number: usize, |
| 26 | + #[serde(rename = "url")] |
| 27 | + pub url: String, |
| 28 | + #[serde(rename = "headRefName")] |
| 29 | + pub head_ref_name: String, |
| 30 | + #[serde(rename = "headRefOid")] |
| 31 | + pub head_ref_oid: SerializedNonZeroOid, |
| 32 | + #[serde(rename = "baseRefName")] |
| 33 | + pub base_ref_name: String, |
| 34 | + #[serde(rename = "closed")] |
| 35 | + pub closed: bool, |
| 36 | + #[serde(rename = "isDraft")] |
| 37 | + pub is_draft: bool, |
| 38 | + #[serde(rename = "title")] |
| 39 | + pub title: String, |
| 40 | + #[serde(rename = "body")] |
| 41 | + pub body: String, |
| 42 | +} |
| 43 | + |
| 44 | +/// Interface for interacting with GitHub. |
| 45 | +pub trait GitHubClient: Debug { |
| 46 | + /// Fetch a list of pull requests for the current repository. If `head_ref_prefix` is provided, |
| 47 | + /// the PR list will be filtered to PRs whose head refs match the pattern. Returns a mapping |
| 48 | + /// from a remote branch name to pull request information. |
| 49 | + fn query_repo_pull_requests( |
| 50 | + &self, |
| 51 | + effects: &Effects, |
| 52 | + head_ref_prefix: Option<&str>, |
| 53 | + ) -> EyreExitOr<HashMap<String, Vec<PullRequestInfo>>>; |
| 54 | + |
| 55 | + /// Create a GitHub issue with the given title and body. Returns the issue's URL. |
| 56 | + /// |
| 57 | + /// GitHub issues and pull requests draw from the same pool of IDs, and |
| 58 | + /// issues can be converted into pull requests. We can create an issue, |
| 59 | + /// use the ID to create a short branch name for a change, and then |
| 60 | + /// convert the issue to a PR with the same ID. |
| 61 | + fn create_issue(&self, effects: &Effects, title: &str, body: &str) -> EyreExitOr<String>; |
| 62 | + |
| 63 | + /// Convert the given issue to a GitHub pull request using the given `HEAD` |
| 64 | + /// and base ref. Returns the PR's URL. |
| 65 | + fn create_pull_request_from_issue( |
| 66 | + &self, |
| 67 | + effects: &Effects, |
| 68 | + issue_num: usize, |
| 69 | + head_ref_name: &str, |
| 70 | + base_ref_name: &str, |
| 71 | + draft: bool, |
| 72 | + ) -> EyreExitOr<String>; |
| 73 | + |
| 74 | + /// Update the specified pull request's title, body, and base ref. |
| 75 | + fn update_pull_request( |
| 76 | + &self, |
| 77 | + effects: &Effects, |
| 78 | + pr_num: usize, |
| 79 | + base_ref_name: &str, |
| 80 | + title: &str, |
| 81 | + body: &str, |
| 82 | + ) -> EyreExitOr<()>; |
| 83 | +} |
| 84 | + |
| 85 | +/// Regular implementation of [`GitHubClient`]. Wraps the `gh` command line |
| 86 | +/// tool. |
| 87 | +#[derive(Debug)] |
| 88 | +pub struct RealGitHubClient {} |
| 89 | +impl RealGitHubClient { |
| 90 | + fn query_current_repo_slug(&self, effects: &Effects) -> EyreExitOr<String> { |
| 91 | + let args = [ |
| 92 | + "repo", |
| 93 | + "view", |
| 94 | + "--json", |
| 95 | + "owner,name", |
| 96 | + "--jq", |
| 97 | + ".owner.login + \"/\" + .name", |
| 98 | + ]; |
| 99 | + self.run_gh_utf8(effects, &args) |
| 100 | + } |
| 101 | + |
| 102 | + #[instrument] |
| 103 | + fn write_body_file(&self, body: &str) -> eyre::Result<NamedTempFile> { |
| 104 | + use std::io::Write; |
| 105 | + let mut body_file = NamedTempFile::new()?; |
| 106 | + body_file.write_all(body.as_bytes())?; |
| 107 | + body_file.flush()?; |
| 108 | + Ok(body_file) |
| 109 | + } |
| 110 | + |
| 111 | + #[instrument] |
| 112 | + fn run_gh_utf8(&self, effects: &Effects, args: &[&str]) -> EyreExitOr<String> { |
| 113 | + let stdout = try_exit_code!(self.run_gh_raw(effects, args)?); |
| 114 | + let result = match std::str::from_utf8(&stdout) { |
| 115 | + Ok(result) => Ok(result.trim().to_owned()), |
| 116 | + Err(err) => { |
| 117 | + writeln!( |
| 118 | + effects.get_output_stream(), |
| 119 | + "Could not parse output from `gh` as UTF-8: {err}", |
| 120 | + )?; |
| 121 | + Err(ExitCode(1)) |
| 122 | + } |
| 123 | + }; |
| 124 | + Ok(result) |
| 125 | + } |
| 126 | + |
| 127 | + #[instrument] |
| 128 | + fn run_gh_raw(&self, effects: &Effects, args: &[&str]) -> EyreExitOr<Vec<u8>> { |
| 129 | + let exe_invocation = format!("{} {}", GH_EXE, args.join(" ")); |
| 130 | + debug!(?exe_invocation, "Invoking gh"); |
| 131 | + let (effects, _progress) = |
| 132 | + effects.start_operation(OperationType::RunTests(Arc::new(exe_invocation.clone()))); |
| 133 | + |
| 134 | + let child = Command::new(GH_EXE) |
| 135 | + .args(args) |
| 136 | + .stdin(Stdio::piped()) |
| 137 | + .stdout(Stdio::piped()) |
| 138 | + .stderr(Stdio::piped()) |
| 139 | + .spawn() |
| 140 | + .context("Invoking `gh` command-line executable")?; |
| 141 | + let output = child |
| 142 | + .wait_with_output() |
| 143 | + .context("Waiting for `gh` invocation")?; |
| 144 | + if !output.status.success() { |
| 145 | + writeln!( |
| 146 | + effects.get_output_stream(), |
| 147 | + "Call to `{exe_invocation}` failed", |
| 148 | + )?; |
| 149 | + writeln!(effects.get_output_stream(), "Stdout:")?; |
| 150 | + writeln!( |
| 151 | + effects.get_output_stream(), |
| 152 | + "{}", |
| 153 | + String::from_utf8_lossy(&output.stdout) |
| 154 | + )?; |
| 155 | + writeln!(effects.get_output_stream(), "Stderr:")?; |
| 156 | + writeln!( |
| 157 | + effects.get_output_stream(), |
| 158 | + "{}", |
| 159 | + String::from_utf8_lossy(&output.stderr) |
| 160 | + )?; |
| 161 | + return Ok(Err(ExitCode::try_from(output.status)?)); |
| 162 | + } |
| 163 | + Ok(Ok(output.stdout)) |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +impl GitHubClient for RealGitHubClient { |
| 168 | + fn query_repo_pull_requests( |
| 169 | + &self, |
| 170 | + effects: &Effects, |
| 171 | + head_ref_prefix: Option<&str>, |
| 172 | + ) -> EyreExitOr<HashMap<String, Vec<PullRequestInfo>>> { |
| 173 | + let args = [ |
| 174 | + "pr", |
| 175 | + "list", |
| 176 | + "--author", |
| 177 | + "@me", |
| 178 | + "--json", |
| 179 | + "number,url,headRefName,headRefOid,baseRefName,closed,isDraft,title,body", |
| 180 | + ]; |
| 181 | + let raw_result = try_exit_code!(self.run_gh_raw(effects, &args)?); |
| 182 | + let pull_request_infos: Vec<PullRequestInfo> = |
| 183 | + serde_json::from_slice(&raw_result).wrap_err("Deserializing output from gh pr list")?; |
| 184 | + let pull_request_infos: HashMap<String, Vec<PullRequestInfo>> = pull_request_infos |
| 185 | + .into_iter() |
| 186 | + .fold(HashMap::new(), |mut acc, pr| { |
| 187 | + let head_ref_name = pr.head_ref_name.clone(); |
| 188 | + if head_ref_prefix.map_or(true, |prefix| head_ref_name.starts_with(prefix)) { |
| 189 | + acc.entry(pr.head_ref_name.clone()).or_default().push(pr); |
| 190 | + } |
| 191 | + acc |
| 192 | + }); |
| 193 | + |
| 194 | + Ok(Ok(pull_request_infos)) |
| 195 | + } |
| 196 | + |
| 197 | + fn create_issue(&self, effects: &Effects, title: &str, body: &str) -> EyreExitOr<String> { |
| 198 | + let body_file = self.write_body_file(body)?; |
| 199 | + let args = [ |
| 200 | + "issue", |
| 201 | + "create", |
| 202 | + "--title", |
| 203 | + title, |
| 204 | + "--body-file", |
| 205 | + body_file.path().to_str().unwrap(), |
| 206 | + ]; |
| 207 | + self.run_gh_utf8(effects, &args) |
| 208 | + } |
| 209 | + |
| 210 | + fn create_pull_request_from_issue( |
| 211 | + &self, |
| 212 | + effects: &Effects, |
| 213 | + issue_num: usize, |
| 214 | + head_ref_name: &str, |
| 215 | + base_ref_name: &str, |
| 216 | + draft: bool, |
| 217 | + ) -> EyreExitOr<String> { |
| 218 | + // See relevant GitHub REST API docs: |
| 219 | + // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request |
| 220 | + let slug = try_exit_code!(self.query_current_repo_slug(effects)?); |
| 221 | + let endpoint = format!("/repos/{}/pulls", slug); |
| 222 | + let issue_field = format!("issue={}", issue_num); |
| 223 | + let head_field = format!("head={}", head_ref_name); |
| 224 | + let base_field = format!("base={}", base_ref_name); |
| 225 | + let draft_field = format!("draft={}", draft); |
| 226 | + let args = [ |
| 227 | + "api", |
| 228 | + &endpoint, |
| 229 | + "-X", |
| 230 | + "POST", |
| 231 | + "-F", |
| 232 | + &issue_field, |
| 233 | + "-F", |
| 234 | + &head_field, |
| 235 | + "-F", |
| 236 | + &base_field, |
| 237 | + "-F", |
| 238 | + &draft_field, |
| 239 | + "-q", |
| 240 | + ".html_url", // jq syntax to get the url out of the response |
| 241 | + ]; |
| 242 | + self.run_gh_utf8(effects, &args) |
| 243 | + } |
| 244 | + |
| 245 | + fn update_pull_request( |
| 246 | + &self, |
| 247 | + effects: &Effects, |
| 248 | + pr_num: usize, |
| 249 | + base_ref_name: &str, |
| 250 | + title: &str, |
| 251 | + body: &str, |
| 252 | + ) -> EyreExitOr<()> { |
| 253 | + let body_file = self.write_body_file(body)?; |
| 254 | + let args = [ |
| 255 | + "pr", |
| 256 | + "edit", |
| 257 | + &pr_num.to_string(), |
| 258 | + "--base", |
| 259 | + &base_ref_name, |
| 260 | + "--title", |
| 261 | + &title, |
| 262 | + "--body-file", |
| 263 | + body_file.path().to_str().unwrap(), |
| 264 | + ]; |
| 265 | + try_exit_code!(self.run_gh_raw(effects, &args)?); |
| 266 | + Ok(Ok(())) |
| 267 | + } |
| 268 | +} |
| 269 | + |
| 270 | +/// A mock client representing the remote Github repository and server. |
| 271 | +#[derive(Debug)] |
| 272 | +pub struct MockGitHubClient { |
| 273 | + /// The path to the remote repository on disk. |
| 274 | + pub remote_repo_path: PathBuf, |
| 275 | +} |
| 276 | +impl GitHubClient for MockGitHubClient { |
| 277 | + fn query_repo_pull_requests( |
| 278 | + &self, |
| 279 | + _effects: &Effects, |
| 280 | + _head_ref_prefix: Option<&str>, |
| 281 | + ) -> EyreExitOr<HashMap<String, Vec<PullRequestInfo>>> { |
| 282 | + Ok(Ok(HashMap::new())) |
| 283 | + } |
| 284 | + |
| 285 | + fn create_issue(&self, _effects: &Effects, _title: &str, _body: &str) -> EyreExitOr<String> { |
| 286 | + Ok(Ok("".to_string())) |
| 287 | + } |
| 288 | + |
| 289 | + fn create_pull_request_from_issue( |
| 290 | + &self, |
| 291 | + _effects: &Effects, |
| 292 | + _issue_num: usize, |
| 293 | + _head_ref_name: &str, |
| 294 | + _base_ref_name: &str, |
| 295 | + _draft: bool, |
| 296 | + ) -> EyreExitOr<String> { |
| 297 | + Ok(Ok("".to_string())) |
| 298 | + } |
| 299 | + |
| 300 | + fn update_pull_request( |
| 301 | + &self, |
| 302 | + _effects: &Effects, |
| 303 | + _pr_num: usize, |
| 304 | + _base_ref_name: &str, |
| 305 | + _title: &str, |
| 306 | + _body: &str, |
| 307 | + ) -> EyreExitOr<()> { |
| 308 | + Ok(Ok(())) |
| 309 | + } |
| 310 | +} |
0 commit comments