use anyhow::{Context, Result, ensure};
use octocrab::Octocrab;
use octocrab::params::State;
use std::env;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub owner: String,
pub name: String,
}
pub fn repo(root: &Path) -> Result<Repo> {
if let Ok(slug) = env::var("GITHUB_REPOSITORY")
&& !slug.is_empty()
{
return parse_slug(&slug);
}
let url = crate::git::origin_url(root)?;
parse_remote(url.trim())
}
pub fn parse_slug(slug: &str) -> Result<Repo> {
let (owner, name) = slug
.split_once('/')
.context("GITHUB_REPOSITORY must be owner/name")?;
ensure!(
!owner.is_empty() && !name.is_empty() && !name.contains('/'),
"GITHUB_REPOSITORY must be owner/name"
);
Ok(Repo {
owner: owner.to_owned(),
name: name.trim_end_matches(".git").to_owned(),
})
}
pub fn parse_remote(url: &str) -> Result<Repo> {
let url = url.trim();
if let Some(rest) = url.strip_prefix("git@") {
let (host, path) = rest
.split_once(':')
.context("ssh remote must be git@host:owner/name")?;
ensure!(
host == "github.com" || host.ends_with(".github.com"),
"origin is not GitHub ({host})"
);
return parse_slug(path);
}
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.or_else(|| url.strip_prefix("ssh://git@"))
.context("origin URL is not ssh or https")?;
let (host, path) = rest.split_once('/').context("origin URL has no path")?;
ensure!(
host == "github.com" || host.ends_with(".github.com"),
"origin is not GitHub ({host})"
);
parse_slug(path)
}
#[must_use]
pub fn base_branch(root: &Path) -> String {
if let Ok(name) = env::var("GITHUB_REF_NAME")
&& !name.is_empty()
{
return name;
}
crate::git::upstream_default_branch(root).unwrap_or_else(|| "main".into())
}
pub fn existing_pr(token: &str, repo: &Repo, head: &str) -> Result<Option<String>> {
block(async {
let crab = client(token)?;
let page = crab
.pulls(&repo.owner, &repo.name)
.list()
.state(State::Open)
.head(format!("{}:{head}", repo.owner))
.per_page(1)
.send()
.await
.context("list pull requests")?;
Ok(page
.items
.into_iter()
.next()
.and_then(|pr| pr.html_url.map(|url| url.to_string())))
})
}
pub fn create_pr(
token: &str,
repo: &Repo,
title: &str,
head: &str,
base: &str,
body: &str,
) -> Result<String> {
block(async {
let crab = client(token)?;
let pr = crab
.pulls(&repo.owner, &repo.name)
.create(title, head, base)
.body(body)
.send()
.await
.context("create pull request")?;
pr.html_url
.map(|url| url.to_string())
.context("created PR has no html_url")
})
}
pub fn ensure_release(
token: &str,
repo: &Repo,
tag: &str,
name: &str,
body: &str,
) -> Result<String> {
block(async {
let crab = client(token)?;
let repos = crab.repos(&repo.owner, &repo.name);
let releases = repos.releases();
match releases.get_by_tag(tag).await {
Ok(release) => return Ok(release.html_url.to_string()),
Err(octocrab::Error::GitHub { source, .. }) if source.status_code.as_u16() == 404 => {}
Err(error) => return Err(error).context("get release by tag"),
}
let release = releases
.create(tag)
.name(name)
.body(body)
.send()
.await
.context("create GitHub release")?;
Ok(release.html_url.to_string())
})
}
fn client(token: &str) -> Result<Octocrab> {
Octocrab::builder()
.personal_token(token.to_owned())
.build()
.context("GitHub client")
}
fn block<F, T>(fut: F) -> Result<T>
where
F: std::future::Future<Output = Result<T>>,
{
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("tokio")?
.block_on(fut)
}
#[cfg(test)]
mod tests {
use super::{parse_remote, parse_slug};
#[test]
fn slug_and_remotes() {
let expected = super::Repo {
owner: "victor-software-house".into(),
name: "verctl".into(),
};
assert_eq!(
parse_slug("victor-software-house/verctl").unwrap(),
expected
);
assert_eq!(
parse_remote("https://github.com/victor-software-house/verctl.git").unwrap(),
expected
);
assert_eq!(
parse_remote("git@github.com:victor-software-house/verctl.git").unwrap(),
expected
);
assert_eq!(
parse_remote("ssh://git@github.com/victor-software-house/verctl.git").unwrap(),
expected
);
}
#[test]
fn rejects_non_github() {
assert!(parse_remote("https://gitlab.com/acme/app.git").is_err());
}
}