use anyhow::{anyhow, Context};
use json::{object, JsonValue};
use log::{error, info, warn};
use std::{env, fs::File, path::PathBuf};
use structopt::StructOpt;
use super::Command;
use crate::{
app::AppSession,
errors::{Error, Result},
graph,
project::Project,
repository::{CommitId, ReleasedProjectInfo},
};
fn maybe_var(key: &str) -> Result<Option<String>> {
if let Some(os_str) = env::var_os(key) {
if let Ok(s) = os_str.into_string() {
if s.len() > 0 {
Ok(Some(s))
} else {
Ok(None)
}
} else {
Err(Error::Environment(format!(
"could not parse environment variable {} as Unicode",
key
)))
}
} else {
Ok(None)
}
}
fn require_var(key: &str) -> Result<String> {
maybe_var(key)?
.ok_or_else(|| Error::Environment(format!("environment variable {} must be provided", key)))
}
struct GitHubInformation {
slug: String,
token: String,
}
impl GitHubInformation {
fn new(sess: &AppSession) -> Result<Self> {
let token = require_var("GITHUB_TOKEN")?;
let upstream_url = sess.repo.upstream_url()?;
info!("upstream url: {}", upstream_url);
let upstream_url = git_url_parse::GitUrl::parse(&upstream_url).map_err(|e| {
Error::Environment(format!(
"cannot parse upstream Git URL `{}`: {}",
upstream_url, e
))
})?;
let slug = upstream_url.fullname;
Ok(GitHubInformation { slug, token })
}
fn make_blocking_client(&self) -> Result<reqwest::blocking::Client> {
use reqwest::header;
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("token {}", self.token))?,
);
headers.insert(header::USER_AGENT, header::HeaderValue::from_str("cranko")?);
Ok(reqwest::blocking::Client::builder()
.default_headers(headers)
.build()?)
}
fn api_url(&self, rest: &str) -> String {
format!("https://api.github.com/repos/{}/{}", self.slug, rest)
}
fn get_release_metadata(
&self,
sess: &AppSession,
proj: &Project,
rel: &ReleasedProjectInfo,
client: &mut reqwest::blocking::Client,
) -> Result<JsonValue> {
let tag_name = sess.repo.get_tag_name(proj, rel)?;
let query_url = self.api_url(&format!("releases/tags/{}", tag_name));
let resp = client.get(&query_url).send()?;
if resp.status().is_success() {
Ok(json::parse(&resp.text()?)?)
} else {
Err(Error::Environment(format!(
"no GitHub release for tag `{}`: {}",
tag_name,
resp.text()
.unwrap_or_else(|_| "[non-textual server response]".to_owned())
)))
}
}
fn create_release(
&self,
sess: &AppSession,
proj: &Project,
rel: &ReleasedProjectInfo,
cid: &CommitId,
client: &mut reqwest::blocking::Client,
) -> Result<JsonValue> {
let tag_name = sess.repo.get_tag_name(proj, rel)?;
let changelog = proj.changelog.scan_changelog(proj, &sess.repo, cid)?;
let release_info = object! {
"tag_name" => tag_name.clone(),
"name" => format!("{} {}", proj.user_facing_name, proj.version),
"body" => changelog,
"draft" => false,
"prerelease" => false,
};
let create_url = self.api_url("releases");
let resp = client
.post(&create_url)
.body(json::stringify(release_info))
.send()?;
let status = resp.status();
let parsed = json::parse(&resp.text()?)?;
if status.is_success() {
info!("created GitHub release for {}", tag_name);
Ok(parsed)
} else {
Err(Error::Environment(format!(
"failed to create GitHub release for {}: {}",
tag_name, parsed
)))
}
}
}
#[derive(Debug, PartialEq, StructOpt)]
pub struct CreateReleasesCommand {
#[structopt(help = "Name(s) of the project(s) to release on GitHub")]
proj_names: Vec<String>,
}
impl Command for CreateReleasesCommand {
fn execute(self) -> anyhow::Result<i32> {
let mut sess = AppSession::initialize()?;
let info = GitHubInformation::new(&sess)?;
sess.populated_graph()?;
let (dev_mode, rel_info) = sess.ensure_ci_release_mode()?;
let rel_commit = rel_info
.commit
.as_ref()
.ok_or_else(|| anyhow!("no commit ID for HEAD (?)"))?;
if dev_mode {
return Err(anyhow!("refusing to proceed in dev mode"));
}
let mut q = graph::GraphQueryBuilder::default();
q.names(self.proj_names);
let no_names = q.no_names();
let idents = sess
.graph()
.query(q)
.context("could not select projects for GitHub release")?;
if idents.len() == 0 {
info!("no projects selected");
return Ok(0);
}
let mut client = info.make_blocking_client()?;
let mut n_released = 0;
for ident in &idents {
let proj = sess.graph().lookup(*ident);
if let Some(rel) = rel_info.lookup_if_released(proj) {
info.create_release(&sess, proj, &rel, rel_commit, &mut client)?;
n_released += 1;
} else if !no_names {
warn!(
"project {} was specified but does not have a new release",
proj.user_facing_name
);
}
}
if no_names && n_released != 1 {
info!(
"created GitHub releases for {} of {} projects",
n_released,
idents.len()
);
} else if n_released != idents.len() {
warn!(
"created GitHub releases for {} of {} selected projects",
n_released,
idents.len()
);
}
Ok(0)
}
}
#[derive(Debug, PartialEq, StructOpt)]
pub struct CredentialHelperCommand {
#[structopt(help = "The operation")]
operation: String,
}
impl Command for CredentialHelperCommand {
fn execute(self) -> anyhow::Result<i32> {
if self.operation != "get" {
info!("ignoring Git credential operation `{}`", self.operation);
} else {
let token = require_var("GITHUB_TOKEN")?;
println!("username=token");
println!("password={}", token);
}
Ok(0)
}
}
#[derive(Debug, PartialEq, StructOpt)]
pub struct InstallCredentialHelperCommand {}
impl Command for InstallCredentialHelperCommand {
fn execute(self) -> anyhow::Result<i32> {
let this_exe = std::env::current_exe()?;
let this_exe = this_exe.to_str().ok_or_else(|| {
anyhow!(
"cannot install cranko as a Git \
credential helper because its executable path is not Unicode"
)
})?;
let mut cfg = git2::Config::open_default().context("cannot open Git configuration")?;
cfg.set_str(
"credential.helper",
&format!("{} github _credential-helper", this_exe),
)
.context("cannot update Git configuration setting `credential.helper`")?;
Ok(0)
}
}
#[derive(Debug, PartialEq, StructOpt)]
pub struct UploadArtifactsCommand {
#[structopt(
long = "overwrite",
help = "Overwrite artifacts if they already exist in the release (default: error out)"
)]
overwrite: bool,
#[structopt(help = "The released project for which to upload content")]
proj_name: String,
#[structopt(help = "The path(s) to the file(s) to upload", required = true)]
paths: Vec<PathBuf>,
}
impl Command for UploadArtifactsCommand {
fn execute(self) -> anyhow::Result<i32> {
let mut sess = AppSession::initialize()?;
let info = GitHubInformation::new(&sess)?;
sess.populated_graph()?;
let rel_info = sess
.repo
.parse_release_info_from_head()
.context("expected Cranko release metadata in the HEAD commit but could not load it")?;
let mut client = info.make_blocking_client()?;
let ident = sess
.graph()
.lookup_ident(&self.proj_name)
.ok_or_else(|| anyhow!("no such project `{}`", self.proj_name))?;
let rel = rel_info
.lookup_if_released(sess.graph().lookup(ident))
.ok_or_else(|| {
anyhow!(
"project `{}` does not seem to be freshly released",
self.proj_name
)
})?;
let proj = sess.graph().lookup(ident);
let mut metadata = info.get_release_metadata(&sess, proj, rel, &mut client)?;
let upload_url = metadata["upload_url"]
.take_string()
.ok_or_else(|| anyhow!("no upload_url in release metadata?"))?;
let upload_url = {
let v: Vec<&str> = upload_url.split('{').collect();
v[0].to_owned()
};
info!("upload url = {}", upload_url);
for path in &self.paths {
let file = File::open(path)?;
let name = path
.file_name()
.ok_or_else(|| anyhow!("input file has no name component??"))?
.to_str()
.ok_or_else(|| anyhow!("input file name cannot be stringified"))?
.to_owned();
if self.overwrite {
for asset_info in metadata["assets"].members() {
if asset_info["name"].as_str() == Some(&name) {
info!("deleting preexisting asset (id {})", asset_info["id"]);
let del_url =
info.api_url(&format!("releases/assets/{}", asset_info["id"]));
let resp = client.delete(&del_url).send()?;
let status = resp.status();
if !status.is_success() {
error!("API response: {}", resp.text()?);
return Err(anyhow!("deletion of pre-existing asset {} failed", name));
}
}
}
}
info!("uploading {} => {}", path.display(), name);
let url = reqwest::Url::parse_with_params(&upload_url, &[("name", &name)])?;
let resp = client
.post(url)
.header(
reqwest::header::ACCEPT,
"application/vnd.github.manifold-preview",
)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(file)
.send()?;
let status = resp.status();
let mut parsed = json::parse(&resp.text()?)?;
if !status.is_success() {
error!("API response: {}", parsed);
return Err(anyhow!("creation of asset {} failed", name));
}
if let Some(s) = parsed["url"].take_string() {
info!(" ... asset url = {}", s);
}
}
info!("success!");
Ok(0)
}
}
#[derive(Debug, PartialEq, StructOpt)]
pub enum GithubCommands {
#[structopt(name = "create-releases")]
CreateReleases(CreateReleasesCommand),
#[structopt(name = "_credential-helper", setting = structopt::clap::AppSettings::Hidden)]
CredentialHelper(CredentialHelperCommand),
#[structopt(name = "install-credential-helper")]
InstallCredentialHelper(InstallCredentialHelperCommand),
#[structopt(name = "upload-artifacts")]
UploadArtifacts(UploadArtifactsCommand),
}
#[derive(Debug, PartialEq, StructOpt)]
pub struct GithubCommand {
#[structopt(subcommand)]
command: GithubCommands,
}
impl Command for GithubCommand {
fn execute(self) -> anyhow::Result<i32> {
match self.command {
GithubCommands::CreateReleases(o) => o.execute(),
GithubCommands::CredentialHelper(o) => o.execute(),
GithubCommands::InstallCredentialHelper(o) => o.execute(),
GithubCommands::UploadArtifacts(o) => o.execute(),
}
}
}