use anyhow::{Context, Result, anyhow, bail};
use std::io::{self, ErrorKind, Write};
use std::path::Path;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq)]
pub struct Issue {
pub number: u64,
pub title: String,
pub body: String,
pub labels: Vec<String>,
}
pub struct PullRequest<'a> {
pub title: &'a str,
pub body: &'a str,
pub base: &'a str,
pub head: &'a str,
pub draft: bool,
pub extra_args: &'a [String],
}
fn gh(dir: &Path, args: &[&str]) -> Command {
let mut command = Command::new("gh");
command.current_dir(dir).args(args);
command
}
fn start_error(err: io::Error) -> anyhow::Error {
if err.kind() == ErrorKind::NotFound {
anyhow!(
"git-agent works with GitHub through the GitHub CLI, which is not installed; get it \
from https://cli.github.com, then run `gh auth login`"
)
} else {
anyhow::Error::new(err).context("failed to run gh")
}
}
fn output(dir: &Path, args: &[&str]) -> Result<String> {
let output = gh(dir, args).output().map_err(start_error)?;
if !output.status.success() {
bail!(
"`gh {}` failed: {}",
args[..2].join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub fn default_branch(dir: &Path) -> Result<String> {
let name = output(
dir,
&[
"repo",
"view",
"--json",
"defaultBranchRef",
"--jq",
".defaultBranchRef.name",
],
)
.context("could not ask GitHub for the default branch; name the base branch with --base")?;
if name.is_empty() {
bail!("GitHub reported no default branch; name the base branch with --base");
}
Ok(name)
}
pub fn issue(dir: &Path, reference: &str) -> Result<Issue> {
let json = output(
dir,
&[
"issue",
"view",
reference,
"--json",
"number,title,body,labels",
],
)?;
parse_issue(&json).with_context(|| format!("could not read issue {reference} from gh"))
}
fn parse_issue(json: &str) -> Result<Issue> {
let value: serde_json::Value = serde_json::from_str(json)?;
let text = |key: &str| value[key].as_str().unwrap_or_default().to_owned();
let labels = value["labels"].as_array().map_or_else(Vec::new, |labels| {
labels
.iter()
.filter_map(|label| label["name"].as_str().map(str::to_owned))
.collect()
});
Ok(Issue {
number: value["number"].as_u64().context("no issue number")?,
title: text("title"),
body: text("body"),
labels,
})
}
pub fn open_pull_request(dir: &Path, branch: &str) -> Result<Option<String>> {
let url = output(
dir,
&[
"pr",
"list",
"--head",
branch,
"--state",
"open",
"--json",
"url",
"--jq",
".[0].url // empty",
],
)?;
Ok((!url.is_empty()).then_some(url))
}
pub fn create_pull_request(dir: &Path, pull_request: &PullRequest) -> Result<()> {
let mut command = gh(
dir,
&[
"pr",
"create",
"--title",
pull_request.title,
"--body-file",
"-",
"--base",
pull_request.base,
"--head",
pull_request.head,
],
);
if pull_request.draft {
command.arg("--draft");
}
let mut child = command
.args(pull_request.extra_args)
.stdin(Stdio::piped())
.spawn()
.map_err(start_error)?;
let written = child
.stdin
.take()
.context("gh has no input")?
.write_all(pull_request.body.as_bytes());
let status = child.wait().context("failed to wait for gh")?;
written.context("failed to send the description to gh")?;
if !status.success() {
bail!("`gh pr create` failed ({status})");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_an_issue() {
let json = r#"{"body":"It hangs.","labels":[{"id":"x","name":"bug"},{"name":"auth"}],"number":42,"title":"Login times out"}"#;
assert_eq!(
parse_issue(json).unwrap(),
Issue {
number: 42,
title: "Login times out".into(),
body: "It hangs.".into(),
labels: vec!["bug".into(), "auth".into()],
}
);
assert!(parse_issue("{}").is_err());
}
}