Skip to content

Commit 3196d16

Browse files
committed
wip github2 forge
1 parent 642bb44 commit 3196d16

File tree

9 files changed

+1064
-1
lines changed

9 files changed

+1064
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@
1010
# Direnv: https://github.com/direnv/direnv
1111
/.direnv/
1212
/.envrc
13+
14+
# Vim swapfiles
15+
.*.sw*

git-branchless-lib/src/core/effects.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub enum OperationType {
4747
SyncCommits,
4848
UpdateCommitGraph,
4949
UpdateCommits,
50+
CreatePullRequests,
5051
WalkCommits,
5152
}
5253

@@ -88,6 +89,7 @@ impl Display for OperationType {
8889
OperationType::SyncCommits => write!(f, "Syncing commit stacks"),
8990
OperationType::UpdateCommits => write!(f, "Updating commits"),
9091
OperationType::UpdateCommitGraph => write!(f, "Updating commit graph"),
92+
OperationType::CreatePullRequests => write!(f, "Creating pull requests"),
9193
OperationType::WalkCommits => write!(f, "Walking commits"),
9294
}
9395
}

git-branchless-opts/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,10 @@ pub enum ForgeKind {
371371
/// branch using the `gh` command-line tool. WARNING: likely buggy!
372372
Github,
373373

374+
/// Force-push branches to the remove and create a pull request for each branch using the `gh`
375+
/// command-line tool. New implementation.
376+
Github2,
377+
374378
/// Submit code reviews to Phabricator using the `arc` command-line tool.
375379
Phabricator,
376380
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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

Comments
 (0)