use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde_json::Value;
use crate::error::{self, Error, Result};
use crate::rules::WaitSeconds;
pub const PROGRAM_ENV: &str = "ONEVCS_GH";
pub const CHECK_SOURCE_ENV: &str = "ONEVCS_CHECK_SOURCE";
pub const CHECKS_TIMEOUT_ENV: &str = "ONEVCS_CHECKS_TIMEOUT_SECONDS";
pub const CHECKS_POLL_ENV: &str = "ONEVCS_CHECKS_POLL_SECONDS";
pub const DRAFT_GRACE_ENV: &str = "ONEVCS_DRAFT_CHECKS_GRACE_SECONDS";
pub const DEFAULT_DRAFT_GRACE_SECONDS: f64 = 120.0;
pub const DEFAULT_HEAD_ACTIVITY_REUSE_SECONDS: f64 = 300.0;
pub const DEFAULT_CHECKS_TIMEOUT_SECONDS: f64 = 3600.0;
pub const DEFAULT_CHECKS_POLL_SECONDS: f64 = 30.0;
pub fn program() -> PathBuf {
std::env::var_os(PROGRAM_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("gh"))
}
#[derive(Debug, Clone)]
pub struct Answer {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl Answer {
pub fn detail(&self) -> String {
if self.stderr.trim().is_empty() {
self.stdout.trim().to_owned()
} else {
self.stderr.trim().to_owned()
}
}
}
pub fn attempt(args: &[&str]) -> Result<Answer> {
attempt_with_env(args, &[])
}
pub fn attempt_with_env(args: &[&str], env: &[(&str, &str)]) -> Result<Answer> {
let output = Command::new(program())
.args(args)
.envs(env.iter().copied())
.stdin(Stdio::null())
.output()
.map_err(error::at("run", &program()))?;
Ok(Answer {
code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
pub fn invoke(args: &[&str]) -> Result<String> {
let answer = attempt(args)?;
if answer.code == Some(0) {
return Ok(answer.stdout);
}
Err(refused(args, &answer))
}
fn refused(args: &[&str], answer: &Answer) -> Error {
Error::Invalid {
reason: format!("gh {} failed: {}", args.join(" "), answer.detail()),
}
}
const ALLOW_ESCAPES: &str = "--allow-escape-sequences";
fn unknown_flag(answer: &Answer) -> bool {
answer
.stderr
.contains(&format!("unknown flag: {ALLOW_ESCAPES}"))
}
pub fn invoke_content(args: &[&str]) -> Result<String> {
let mut asked = args.to_vec();
asked.push(ALLOW_ESCAPES);
let answer = attempt(&asked)?;
if answer.code == Some(0) {
return Ok(answer.stdout);
}
if !unknown_flag(&answer) {
return Err(refused(&asked, &answer));
}
invoke(args)
}
pub fn json(raw: &str) -> Result<Value> {
serde_json::from_str(raw.trim()).map_err(|e| {
error::invalid(format!(
"gh returned output that is not JSON ({e}): {raw:?}"
))
})
}
pub fn checks_timeout_override() -> Result<Option<WaitSeconds>> {
wait(CHECKS_TIMEOUT_ENV)
}
pub fn checks_poll() -> Result<f64> {
seconds(CHECKS_POLL_ENV, DEFAULT_CHECKS_POLL_SECONDS)
}
pub fn draft_grace() -> Result<f64> {
seconds(DRAFT_GRACE_ENV, DEFAULT_DRAFT_GRACE_SECONDS)
}
pub fn head_activity_reuse() -> Result<f64> {
Ok(DEFAULT_HEAD_ACTIVITY_REUSE_SECONDS * checks_poll()? / DEFAULT_CHECKS_POLL_SECONDS)
}
fn seconds(name: &str, default: f64) -> Result<f64> {
Ok(wait(name)?.map_or(default, WaitSeconds::get))
}
fn wait(name: &str) -> Result<Option<WaitSeconds>> {
let Some(raw) = std::env::var_os(name) else {
return Ok(None);
};
let raw = raw.to_string_lossy().into_owned();
let value: f64 = raw.trim().parse().map_err(|_| Error::Invalid {
reason: format!("{name} must be a number of seconds, not {raw:?}"),
})?;
WaitSeconds::try_from(value)
.map(Some)
.map_err(|_| Error::Invalid {
reason: format!("{name} must be {}, not {raw:?}", WaitSeconds::RANGE),
})
}
pub const HOST: &str = "github.com";
pub fn slug(identity: &str) -> Option<String> {
let mut parts = identity.split('/');
let host = parts.next()?;
let owner = parts.next()?;
let name = parts.next()?;
if parts.next().is_some() || host != HOST || owner.is_empty() || name.is_empty() {
return None;
}
Some(format!("{owner}/{name}"))
}
const UNANSWERED: &str = "the host did not answer: ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unanswered {
pub said: String,
pub detail: String,
}
impl Unanswered {
pub fn into_error(self) -> Error {
Error::Invalid {
reason: format!(
"{UNANSWERED}{}\n{}",
self.said.replace('\n', " "),
self.detail
),
}
}
pub fn of(error: &Error) -> Option<Unanswered> {
let Error::Invalid { reason } = error else {
return None;
};
let (said, detail) = reason.strip_prefix(UNANSWERED)?.split_once('\n')?;
if said.is_empty() {
return None;
}
Some(Unanswered {
said: said.to_owned(),
detail: detail.to_owned(),
})
}
pub fn read(api: &str, answer: &Answer) -> Option<Unanswered> {
if answer.code == Some(0) {
return None;
}
let response = Response::read(&answer.stdout);
let detail = answer.detail();
let said = match response.status {
None => format!("{api} gave no HTTP response"),
Some(status) if status >= 500 => {
format!("{api} failed on the host's side: HTTP {status}")
}
Some(status @ (403 | 429)) => {
let message = format!("{detail} {}", response.body).to_lowercase();
let remaining = response.count("x-ratelimit-remaining");
let retry = response.count("retry-after");
if status == 403
&& remaining != Some(0)
&& retry.is_none()
&& !message.contains("rate limit")
{
return None;
}
let which = if message.contains("secondary rate limit") {
"a secondary rate limit"
} else {
"its rate limit"
};
let mut said = format!("{api} is refused for {which}: HTTP {status}");
if let Some(limit) = response.count("x-ratelimit-limit") {
said.push_str(&format!(", limit {limit}"));
if let Some(resource) = response.word("x-ratelimit-resource") {
said.push_str(&format!(" ({resource})"));
}
}
if let Some(remaining) = remaining {
said.push_str(&format!(", {remaining} remaining"));
}
if let Some(reset) = response.moment("x-ratelimit-reset") {
said.push_str(&format!(", resets at {}", crate::ids::stamp(reset)));
}
if let Some(retry) = retry {
said.push_str(&format!(", retry after {retry}s"));
}
said
}
Some(_) => return None,
};
Some(Unanswered { said, detail })
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Response {
pub status: Option<u16>,
headers: Vec<(String, String)>,
pub body: String,
}
impl Response {
pub fn read(stdout: &str) -> Response {
let Some(rest) = stdout.strip_prefix("HTTP/") else {
return Response {
body: stdout.to_owned(),
..Response::default()
};
};
let mut lines = rest.split_inclusive('\n');
let status = lines
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse().ok());
let mut headers = Vec::new();
let mut consumed = 0;
for line in lines.by_ref() {
consumed += line.len();
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
headers.push((name.trim().to_owned(), value.trim().to_owned()));
}
}
let start = rest.find('\n').map_or(rest.len(), |end| end + 1) + consumed;
Response {
status,
headers,
body: rest.get(start..).unwrap_or_default().to_owned(),
}
}
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
fn count(&self, name: &str) -> Option<u64> {
self.header(name)?.parse().ok()
}
fn moment(&self, name: &str) -> Option<time::OffsetDateTime> {
let epoch: i64 = self.header(name)?.parse().ok()?;
time::OffsetDateTime::from_unix_timestamp(epoch).ok()
}
fn word(&self, name: &str) -> Option<&str> {
self.header(name).filter(|value| {
(1..=40).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
})
}
}