#[cfg(feature = "process")]
use std::num::NonZeroUsize;
#[cfg(feature = "process")]
use std::time::Duration;
use lgwks_std::json::{self, Deserialize, Serialize};
use crate::cap::{Auth, Cap};
use crate::error::{BotError, DispatchCertainty};
use crate::verb;
#[cfg(feature = "process")]
use super::sys::{DEFAULT_CAPTURE_LIMIT, DEFAULT_DEADLINE};
#[cfg(feature = "process")]
use crate::rt::process::{ProcessRun, ProcessSpec};
#[cfg(feature = "process")]
use crate::rt::supervise::Supervisor;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Repository {
spec: String,
}
impl Repository {
pub fn new(spec: impl Into<String>) -> Result<Self, GhError> {
let spec = spec.into();
let invalid = |reason: &'static str| GhError::Repository {
spec: spec.clone(),
reason,
};
if spec.is_empty() {
let refusal = Err(invalid("the repository reference is empty"));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
if spec.len() > MAX_REPOSITORY_BYTES {
let refusal = Err(invalid("the repository reference is too long"));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
let Some((owner, rest)) = spec.split_once('/') else {
let refusal = Err(invalid("both `owner` and `repo` must be non-empty"));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
};
if rest.contains('/') {
let refusal = Err(invalid(
"a repository reference is `owner/repo`, with exactly one `/`",
));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
let name = rest;
if owner.is_empty() || name.is_empty() {
let refusal = Err(invalid("both `owner` and `repo` must be non-empty"));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
if [owner, name]
.iter()
.any(|segment| matches!(*segment, "." | ".."))
{
let refusal = Err(invalid(
"`.` and `..` are path segments, not repository names",
));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
let allowed = |segment: &str| {
segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
};
if !allowed(owner) || !allowed(name) {
let refusal = Err(invalid(
"`owner` and `repo` hold ASCII letters, digits, '-', '_' and '.' only",
));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
Ok(Self { spec })
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.spec
}
}
impl std::fmt::Display for Repository {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.spec)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CommitId(String);
impl CommitId {
pub fn new(hex: impl Into<String>) -> Result<Self, GhError> {
let hex = hex.into();
if hex.len() != SHA_HEX_LEN {
let refusal = Err(GhError::CommitId {
hex,
reason: "a commit id is 40 hexadecimal characters",
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
if !hex
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
let refusal = Err(GhError::CommitId {
hex,
reason: "a commit id is lowercase hexadecimal",
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
Ok(Self(hex))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CommitId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
pub const SHA_HEX_LEN: usize = 40;
pub const MAX_REPOSITORY_BYTES: usize = 256;
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct ReviewRecord {
#[serde(default)]
id: u64,
#[serde(default)]
commit_id: Option<String>,
#[serde(default)]
state: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
comment_count: Option<u64>,
#[serde(default)]
user: Option<ReviewAuthor>,
}
impl ReviewRecord {
#[must_use]
pub const fn id(&self) -> u64 {
self.id
}
#[must_use]
pub fn commit_id(&self) -> Option<&str> {
self.commit_id.as_deref()
}
#[must_use]
pub fn state(&self) -> &str {
&self.state
}
#[must_use]
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
#[must_use]
pub const fn user(&self) -> Option<&ReviewAuthor> {
self.user.as_ref()
}
#[must_use]
pub fn applied_comments(&self) -> usize {
let Some(count) = self.comment_count else {
return 0;
};
match usize::try_from(count) {
Ok(applied) => applied,
Err(_wider_than_this_host_counts) => usize::MAX,
}
}
#[must_use]
pub fn is_pending(&self) -> bool {
self.state == REVIEW_STATE_PENDING
}
#[must_use]
pub fn matches_subject_body(&self, intended: &ReviewPayload) -> bool {
self.commit_id.as_deref() == Some(intended.commit_id.as_str())
&& self.body.as_deref() == Some(intended.body.as_str())
}
#[must_use]
pub fn matches_except_comments(&self, intended: &ReviewPayload) -> bool {
self.matches_subject_body(intended) && self.state == intended.state()
}
#[must_use]
pub fn new(id: u64, commit_id: &str, state: &str, body: &str) -> Self {
Self {
id,
commit_id: Some(String::from(commit_id)),
state: String::from(state),
body: Some(String::from(body)),
comment_count: None,
user: None,
}
}
#[must_use]
pub fn matches(&self, intended: &ReviewPayload) -> bool {
self.matches_except_comments(intended)
&& self.applied_comments() == intended.comments().len()
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct ReviewAuthor {
#[serde(default)]
login: Option<String>,
}
impl ReviewAuthor {
#[must_use]
pub fn login(&self) -> Option<&str> {
self.login.as_deref()
}
}
pub const REVIEW_STATE_PENDING: &str = "PENDING";
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct ReviewComment {
path: String,
line: u64,
body: String,
}
impl ReviewComment {
#[must_use]
pub fn new(path: impl Into<String>, line: u64, body: impl Into<String>) -> Self {
Self {
path: path.into(),
line,
body: body.into(),
}
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub const fn line(&self) -> u64 {
self.line
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct ChangedFile {
#[serde(default)]
filename: String,
#[serde(default)]
status: String,
#[serde(default)]
additions: u64,
#[serde(default)]
deletions: u64,
#[serde(default)]
patch: Option<String>,
}
impl ChangedFile {
#[must_use]
pub fn filename(&self) -> &str {
&self.filename
}
#[must_use]
pub fn status(&self) -> &str {
&self.status
}
#[must_use]
pub const fn additions(&self) -> u64 {
self.additions
}
#[must_use]
pub const fn deletions(&self) -> u64 {
self.deletions
}
#[must_use]
pub fn patch(&self) -> Option<&str> {
self.patch.as_deref()
}
#[must_use]
pub fn patch_bytes(&self) -> usize {
self.patch.as_ref().map_or(0, String::len)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct PullDiff {
number: u64,
files: Vec<ChangedFile>,
patch_bytes: usize,
}
impl PullDiff {
#[must_use]
pub const fn number(&self) -> u64 {
self.number
}
#[must_use]
pub fn files(&self) -> &[ChangedFile] {
&self.files
}
#[must_use]
pub fn len(&self) -> usize {
self.files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
#[must_use]
pub const fn patch_bytes(&self) -> usize {
self.patch_bytes
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(crate = "lgwks_std::json::serde", from = "PullWire")]
#[non_exhaustive]
pub struct PrSnapshot {
number: u64,
head_sha: String,
base_sha: String,
}
#[derive(Deserialize)]
#[serde(crate = "lgwks_std::json::serde")]
struct PullWire {
#[serde(default)]
number: u64,
#[serde(default)]
head: RefWire,
#[serde(default)]
base: RefWire,
}
#[derive(Default, Deserialize)]
#[serde(crate = "lgwks_std::json::serde")]
struct RefWire {
#[serde(default)]
sha: String,
}
#[cfg(feature = "process")]
#[derive(Default, Deserialize)]
#[serde(crate = "lgwks_std::json::serde")]
struct MovedWire {
#[serde(default)]
message: Option<String>,
#[serde(default)]
url: Option<String>,
}
#[cfg(feature = "process")]
impl MovedWire {
fn canonical(&self) -> Option<String> {
let message = self.message.as_deref()?;
if !message.to_ascii_lowercase().contains("moved") {
return None;
}
canonical_repo_from_url(self.url.as_deref()?)
}
}
#[cfg(feature = "process")]
fn canonical_repo_from_url(url: &str) -> Option<String> {
let (_, tail) = url.split_once("/repos/")?;
let mut segments = tail.split('/');
let owner = segments.next()?;
let repo = segments.next()?;
let spec = format!("{owner}/{repo}");
Repository::new(spec)
.ok()
.map(|repo| repo.as_str().to_owned())
}
impl From<PullWire> for PrSnapshot {
fn from(wire: PullWire) -> Self {
Self {
number: wire.number,
head_sha: wire.head.sha,
base_sha: wire.base.sha,
}
}
}
impl PrSnapshot {
#[must_use]
pub const fn number(&self) -> u64 {
self.number
}
#[must_use]
pub fn head_sha(&self) -> &str {
&self.head_sha
}
#[must_use]
pub fn base_sha(&self) -> &str {
&self.base_sha
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct PullRequest {
repository: Repository,
number: u64,
}
impl PullRequest {
#[must_use]
pub const fn repository(&self) -> &Repository {
&self.repository
}
#[must_use]
pub const fn number(&self) -> u64 {
self.number
}
}
impl PullRequest {
#[must_use]
pub fn new(repository: Repository, number: u64) -> Self {
Self { repository, number }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct ReviewPayload {
commit_id: String,
event: String,
body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
comments: Vec<ReviewComment>,
#[serde(skip)]
marker: String,
}
impl ReviewPayload {
#[must_use]
pub fn commit_id(&self) -> &str {
&self.commit_id
}
#[must_use]
pub fn event(&self) -> &str {
&self.event
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn state(&self) -> &'static str {
match self.event.as_str() {
"APPROVE" => "APPROVED",
"REQUEST_CHANGES" => "CHANGES_REQUESTED",
_ => "COMMENTED",
}
}
#[must_use]
pub fn marker(&self) -> &str {
&self.marker
}
#[must_use]
pub fn comments(&self) -> &[ReviewComment] {
&self.comments
}
}
impl ReviewPayload {
pub const EVENTS: [&'static str; 3] = ["COMMENT", "APPROVE", "REQUEST_CHANGES"];
pub const MAX_MARKER_BYTES: usize = 256;
pub fn new(
subject: &CommitId,
event: impl Into<String>,
body: impl Into<String>,
marker: impl Into<String>,
) -> Result<Self, GhError> {
let event = event.into();
if !Self::EVENTS.contains(&event.as_str()) {
let refusal = Err(GhError::Event { event });
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
let marker = marker.into();
let invalid = |reason: &'static str| GhError::Marker {
marker: marker.clone(),
reason,
};
if marker.len() > Self::MAX_MARKER_BYTES {
let refusal = Err(invalid("a marker is at most 256 bytes"));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
if marker.contains("--") || marker.contains(['<', '>']) || marker.contains(char::is_control)
{
let refusal = Err(invalid(
"a marker travels inside an HTML comment, so it may not hold `--`, `<`, `>` or a control character",
));
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
let mut body = body.into();
if !marker.is_empty() {
body.push_str("\n\n<!-- lgwks-review:");
body.push_str(&marker);
body.push_str(" -->");
}
Ok(Self {
commit_id: subject.as_str().to_owned(),
event,
body,
comments: Vec::new(),
marker,
})
}
#[must_use]
pub fn with_comments(mut self, comments: Vec<ReviewComment>) -> Self {
self.comments = comments;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GhOutcome {
exit_code: Option<i32>,
stdout: String,
stderr: String,
stdout_truncated: bool,
stderr_truncated: bool,
stdout_total_bytes: u64,
stderr_total_bytes: u64,
deadline_fired: bool,
cleanup_confirmed: bool,
}
impl GhOutcome {
#[must_use]
pub const fn exit_code(&self) -> Option<i32> {
self.exit_code
}
#[must_use]
pub fn stdout(&self) -> &str {
&self.stdout
}
#[must_use]
pub fn stderr(&self) -> &str {
&self.stderr
}
#[must_use]
pub const fn stdout_truncated(&self) -> bool {
self.stdout_truncated
}
#[must_use]
pub const fn stderr_truncated(&self) -> bool {
self.stderr_truncated
}
#[must_use]
pub const fn stdout_total_bytes(&self) -> u64 {
self.stdout_total_bytes
}
#[must_use]
pub const fn stderr_total_bytes(&self) -> u64 {
self.stderr_total_bytes
}
#[must_use]
pub const fn deadline_fired(&self) -> bool {
self.deadline_fired
}
#[must_use]
pub const fn cleanup_confirmed(&self) -> bool {
self.cleanup_confirmed
}
#[must_use]
pub fn http_status(&self) -> Option<u16> {
http_status_in(&self.stderr)
}
}
fn http_status_in(text: &str) -> Option<u16> {
let (_, rest) = text.rsplit_once("HTTP ")?;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
digits.parse::<u16>().ok()
}
impl GhOutcome {
#[must_use]
pub fn succeeded(&self) -> bool {
self.exit_code == Some(0) && !self.deadline_fired
}
pub fn parse_json<T: json::serde::de::DeserializeOwned>(&self) -> Result<T, GhError> {
if self.stdout_truncated {
let refusal = Err(GhError::TruncatedResponse {
retained: self.stdout.len(),
total: self.stdout_total_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "parse_json: returning an error to the caller");
return refusal;
}
json::from_str::<T>(self.stdout.trim()).map_err(|source| GhError::MalformedResponse {
path: String::from("stdout"),
source: source.to_string(),
})
}
}
pub const MAX_REVIEWS_PER_PULL: usize = 1_000;
pub const MAX_DIFF_FILES_PER_PULL: usize = 3_000;
pub const MAX_DIFF_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone)]
pub struct Gh {
repository: Repository,
program: String,
#[cfg(feature = "process")]
capture: NonZeroUsize,
#[cfg(feature = "process")]
deadline: Option<Duration>,
#[cfg(feature = "process")]
env: Vec<(String, String)>,
caps: Vec<Cap>,
}
impl Gh {
#[must_use]
pub fn new(repository: Repository) -> Self {
Self {
repository,
program: String::from("gh"),
#[cfg(feature = "process")]
capture: DEFAULT_CAPTURE_LIMIT,
#[cfg(feature = "process")]
deadline: Some(DEFAULT_DEADLINE),
#[cfg(feature = "process")]
env: Vec::new(),
caps: vec![Cap::sys(), Cap::net()],
}
}
#[must_use]
pub fn program(mut self, program: impl Into<String>) -> Self {
self.program = program.into();
self
}
#[cfg(feature = "process")]
#[must_use]
pub fn capture_limit(mut self, limit: NonZeroUsize) -> Self {
self.capture = limit;
self
}
#[cfg(feature = "process")]
#[must_use]
pub fn deadline(mut self, deadline: Option<Duration>) -> Self {
self.deadline = deadline;
self
}
#[cfg(feature = "process")]
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl AsRef<std::ffi::OsStr>) -> Self {
self.env
.push((key.into(), value.as_ref().to_string_lossy().into_owned()));
self
}
#[must_use]
pub fn required_caps(&self) -> &[Cap] {
&self.caps
}
#[must_use]
pub fn repository(&self) -> &Repository {
&self.repository
}
#[must_use]
pub fn api_path(&self, suffix: &str) -> String {
format!("repos/{}{suffix}", self.repository.as_str())
}
#[cfg(feature = "process")]
async fn call(&self, args: &[String]) -> Result<GhOutcome, GhError> {
self.run_spec(args).await
}
#[cfg(not(feature = "process"))]
async fn call(&self, _args: &[String]) -> Result<GhOutcome, GhError> {
Err(GhError::NoRunner)
}
#[cfg(feature = "process")]
fn spec_for(&self, args: &[String]) -> ProcessSpec {
let mut spec = ProcessSpec::new(&self.program);
for arg in args {
spec.arg(arg);
}
spec.stdin(crate::rt::process::StdioPolicy::Null);
spec.capture_stdout(self.capture);
spec.capture_stderr(self.capture);
if let Some(deadline) = self.deadline {
spec.deadline(deadline);
}
for delta in self.env.iter() {
spec.env(&delta.0, &delta.1);
}
spec
}
#[cfg(feature = "process")]
async fn run_spec(&self, args: &[String]) -> Result<GhOutcome, GhError> {
let spec = self.spec_for(args);
let mut supervisor = Supervisor::new(1);
match supervisor.run_process(&spec).await {
Ok(run) => Ok(outcome_of(&run)),
Err(source) => Err(GhError::Process(source)),
}
}
#[must_use]
pub fn args_for(&self, rest: &[&str]) -> Vec<String> {
let mut args = vec![String::from("api")];
args.extend(rest.iter().map(|arg| (*arg).to_owned()));
args
}
#[cfg(not(feature = "process"))]
pub async fn snapshot(&self, _pull: &PullRequest) -> Result<PrSnapshot, GhError> {
Err(GhError::NoRunner)
}
#[cfg(feature = "process")]
pub async fn snapshot(&self, pull: &PullRequest) -> Result<PrSnapshot, GhError> {
let path = format!(
"repos/{}/pulls/{}",
pull.repository().as_str(),
pull.number()
);
let args = self.args_for(&["--method", "GET", &path]);
let outcome = self.call(&args).await?;
outcome.require_success("reading the pull request head")?;
let snapshot = outcome.parse_json::<PrSnapshot>()?;
if snapshot.head_sha().is_empty()
&& snapshot.base_sha().is_empty()
&& let Ok(moved) = outcome.parse_json::<MovedWire>()
&& let Some(canonical) = moved.canonical()
{
let refusal = Err(GhError::MovedRepository {
requested: self.repository.as_str().to_owned(),
canonical,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "snapshot: returning an error to the caller");
return refusal;
}
if snapshot.head_sha().len() != SHA_HEX_LEN || snapshot.base_sha().len() != SHA_HEX_LEN {
let refusal = Err(GhError::Response {
path,
reason: String::from("the pull request has no 40-character head and base"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "snapshot: returning an error to the caller");
return refusal;
}
Ok(snapshot)
}
#[cfg(not(feature = "process"))]
pub async fn read_reviews(&self, _pull: &PullRequest) -> Result<Vec<ReviewRecord>, GhError> {
Err(GhError::NoRunner)
}
#[cfg(not(feature = "process"))]
pub async fn read_diff(&self, _pull: &PullRequest) -> Result<PullDiff, GhError> {
Err(GhError::NoRunner)
}
#[cfg(feature = "process")]
pub async fn read_reviews(&self, pull: &PullRequest) -> Result<Vec<ReviewRecord>, GhError> {
let path = format!(
"repos/{}/pulls/{}/reviews",
pull.repository().as_str(),
pull.number()
);
let args = self.args_for(&["--method", "GET", &path, "--paginate"]);
let outcome = self.call(&args).await?;
outcome.require_success("reading the pull request's reviews")?;
let reviews = outcome.parse_json::<Vec<ReviewRecord>>()?;
if reviews.len() > MAX_REVIEWS_PER_PULL {
let refusal = Err(GhError::ReviewCeiling {
path,
reviews: reviews.len(),
ceiling: MAX_REVIEWS_PER_PULL,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "read_reviews: returning an error to the caller");
return refusal;
}
Ok(reviews)
}
#[cfg(feature = "process")]
pub async fn read_diff(&self, pull: &PullRequest) -> Result<PullDiff, GhError> {
let path = format!(
"repos/{}/pulls/{}/files",
pull.repository().as_str(),
pull.number()
);
let args = self.args_for(&["--method", "GET", &path, "--paginate"]);
let outcome = self.call(&args).await?;
if !outcome.succeeded() {
if outcome.http_status() == Some(406) {
let refusal = Err(GhError::DiffUnavailable {
path,
reason: outcome.stderr().trim().to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "read_diff: returning an error to the caller");
return refusal;
}
outcome.require_success("reading the pull request's changed files")?;
}
let files = outcome.parse_json::<Vec<ChangedFile>>()?;
if files.len() > MAX_DIFF_FILES_PER_PULL {
let refusal = Err(GhError::DiffFileCeiling {
path,
files: files.len(),
ceiling: MAX_DIFF_FILES_PER_PULL,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "read_diff: returning an error to the caller");
return refusal;
}
let patch_bytes = files.iter().fold(0usize, |total, file| {
total.saturating_add(file.patch_bytes())
});
if patch_bytes > MAX_DIFF_BYTES {
let refusal = Err(GhError::DiffTooLarge {
path,
bytes: patch_bytes,
ceiling: MAX_DIFF_BYTES,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "read_diff: returning an error to the caller");
return refusal;
}
Ok(PullDiff {
number: pull.number(),
files,
patch_bytes,
})
}
#[cfg(not(feature = "process"))]
pub async fn publish(
&self,
_pull: &PullRequest,
_payload: &ReviewPayload,
) -> Result<u64, GhError> {
Err(GhError::NoRunner)
}
#[cfg(feature = "process")]
pub async fn publish(
&self,
pull: &PullRequest,
payload: &ReviewPayload,
) -> Result<u64, GhError> {
let body = json::to_string(payload).map_err(|source| GhError::PayloadNotSent {
reason: source.to_string(),
})?;
let path = format!(
"repos/{}/pulls/{}/reviews",
pull.repository().as_str(),
pull.number()
);
let staging = stage_input(body.as_bytes())?;
let input_path = staging.path_str().to_owned();
let args = self.args_for(&["--method", "POST", &path, "--input", &input_path]);
let outcome = self.run_spec(&args).await?;
outcome.require_success("creating the review")?;
let created = outcome.parse_json::<ReviewRecord>()?;
if created.id() == 0 {
let refusal = Err(GhError::Response {
path,
reason: String::from("the created review has no id"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "publish: returning an error to the caller");
return refusal;
}
Ok(created.id())
}
}
#[cfg(feature = "process")]
impl GhOutcome {
fn require_success(&self, what: &str) -> Result<(), GhError> {
if self.deadline_fired {
let refusal = Err(GhError::Deadline {
what: what.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "require_success: returning an error to the caller");
return refusal;
}
match self.exit_code {
Some(0) => Ok(()),
Some(code) => {
if let Some(status @ (401 | 403 | 404)) = self.http_status() {
let refusal = Err(GhError::Unauthorized {
what: what.to_owned(),
status,
reason: self.stderr.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "require_success: returning an error to the caller");
return refusal;
}
Err(GhError::Transport {
what: what.to_owned(),
exit_code: Some(code),
stderr: self.stderr.clone(),
})
}
None => Err(GhError::Transport {
what: what.to_owned(),
exit_code: None,
stderr: self.stderr.clone(),
}),
}
}
}
#[cfg(feature = "process")]
fn outcome_of(run: &ProcessRun) -> GhOutcome {
let stdout = run.stdout();
let stderr = run.stderr();
GhOutcome {
exit_code: run.exit_code(),
stdout: String::from_utf8_lossy(stdout.bytes()).into_owned(),
stderr: String::from_utf8_lossy(stderr.bytes()).into_owned(),
stdout_truncated: stdout.truncated(),
stderr_truncated: stderr.truncated(),
stdout_total_bytes: stdout.total_bytes(),
stderr_total_bytes: stderr.total_bytes(),
deadline_fired: run.deadline_fired(),
cleanup_confirmed: matches!(
run.cleanup(),
crate::rt::supervise::CleanupReceipt::CleanupConfirmed
),
}
}
#[cfg(feature = "process")]
fn unique_tag() -> Option<String> {
#[cfg(feature = "ephemeral")]
{
let bytes = lgwks_std::random::bytes::<8>().ok()?;
Some(bytes.iter().fold(String::new(), |mut text, byte| {
use std::fmt::Write as _;
let _written = write!(text, "{byte:02x}");
text
}))
}
#[cfg(not(feature = "ephemeral"))]
{
None
}
}
#[cfg(feature = "process")]
struct StagedInput(String);
#[cfg(feature = "process")]
impl StagedInput {
fn path_str(&self) -> &str {
&self.0
}
}
#[cfg(feature = "process")]
impl Drop for StagedInput {
fn drop(&mut self) {
if let Err(cause) = std::fs::remove_file(&self.0)
&& cause.kind() != std::io::ErrorKind::NotFound
{
lgwks_std::trace::warn!(
path = %self.0,
error = %cause,
"the staged review payload could not be removed"
);
}
}
}
#[cfg(feature = "process")]
fn stage_input(input: &[u8]) -> Result<StagedInput, GhError> {
use std::io::Write as _;
let mut path = std::env::temp_dir();
let Some(tag) = unique_tag() else {
let refusal = Err(GhError::Staging {
path: path.display().to_string(),
source: String::from(
"no entropy source to name the staged payload uniquely; a build without the \
`ephemeral` feature refuses to publish rather than reuse a name",
),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "stage_input: returning an error to the caller");
return refusal;
};
path.push(format!("lgwks-gh-payload-{tag}.json"));
let Some(text) = path.to_str().map(str::to_owned) else {
let refusal = Err(GhError::Staging {
path: path.display().to_string(),
source: String::from(
"the temporary directory is not UTF-8, so it cannot be an argument",
),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "stage_input: returning an error to the caller");
return refusal;
};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(&path).map_err(|source| GhError::Staging {
path: path.display().to_string(),
source: source.to_string(),
})?;
file.write_all(input).map_err(|source| GhError::Staging {
path: path.display().to_string(),
source: source.to_string(),
})?;
file.sync_all().map_err(|source| GhError::Staging {
path: path.display().to_string(),
source: source.to_string(),
})?;
Ok(StagedInput(text))
}
#[derive(Debug)]
#[non_exhaustive]
pub enum GhError {
Repository {
spec: String,
reason: &'static str,
},
CommitId {
hex: String,
reason: &'static str,
},
Event {
event: String,
},
Marker {
marker: String,
reason: &'static str,
},
#[cfg(feature = "process")]
Process(crate::rt::process::ProcessRunError),
NoRunner,
Transport {
what: String,
exit_code: Option<i32>,
stderr: String,
},
Deadline {
what: String,
},
Response {
path: String,
reason: String,
},
TruncatedResponse {
retained: usize,
total: u64,
},
ReviewCeiling {
path: String,
reviews: usize,
ceiling: usize,
},
Unauthorized {
what: String,
status: u16,
reason: String,
},
MovedRepository {
requested: String,
canonical: String,
},
DiffUnavailable {
path: String,
reason: String,
},
DiffFileCeiling {
path: String,
files: usize,
ceiling: usize,
},
DiffTooLarge {
path: String,
bytes: usize,
ceiling: usize,
},
MalformedResponse {
path: String,
source: String,
},
PayloadNotSent {
reason: String,
},
Staging {
path: String,
source: String,
},
}
fn credential_caps() -> [Cap; 1] {
[Cap::net()]
}
impl GhError {
#[must_use]
pub fn repair(&self) -> Option<crate::spec::NeedSet> {
match *self {
Self::Unauthorized { .. } => Some(crate::spec::NeedSet::expired_credentials(
"gh",
&credential_caps(),
)),
_ => None,
}
}
fn credential_rejection(&self, domain: &str) -> Option<BotError> {
match *self {
Self::Unauthorized { status, .. } => Some(crate::cap::upstream_credential_rejection(
domain,
status,
&credential_caps(),
)),
_ => None,
}
}
#[must_use]
pub const fn is_read_only_retryable(&self) -> bool {
matches!(
*self,
Self::Deadline { .. }
| Self::MalformedResponse { .. }
| Self::Transport { .. }
| Self::ReviewCeiling { .. }
)
}
#[must_use]
pub const fn is_coverage_incomplete(&self) -> bool {
matches!(
*self,
Self::DiffUnavailable { .. } | Self::DiffFileCeiling { .. } | Self::DiffTooLarge { .. }
)
}
}
impl std::fmt::Display for GhError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::Repository { ref spec, reason } => {
write!(formatter, "{spec:?}: not a repository reference: {reason}")
}
Self::CommitId { ref hex, reason } => {
write!(formatter, "{hex:?}: not a commit id: {reason}")
}
Self::Event { ref event } => write!(
formatter,
"{event:?}: not a review event; expected one of {}",
ReviewPayload::EVENTS.join(", ")
),
Self::Marker { ref marker, reason } => {
write!(formatter, "{marker:?}: not a review marker: {reason}")
}
#[cfg(feature = "process")]
Self::Process(ref source) => {
write!(formatter, "the GitHub client could not run: {source}")
}
Self::NoRunner => formatter.write_str(
"this build has no supervised process runner, so the GitHub client cannot run; \
rebuild with the `process` feature",
),
Self::Transport {
ref what,
exit_code,
ref stderr,
} => {
let status = match exit_code {
Some(code) => format!("exit {code}"),
None => String::from("no exit code"),
};
write!(formatter, "{what} failed ({status}): {stderr:?}")
}
Self::Deadline { ref what } => {
write!(
formatter,
"{what} was stopped by its deadline before it answered"
)
}
Self::Response {
ref path,
ref reason,
} => write!(formatter, "{path}: {reason}"),
Self::TruncatedResponse { retained, total } => write!(
formatter,
"the response was truncated at the capture ceiling: {retained} of {total} bytes \
retained, so it was not decoded"
),
Self::ReviewCeiling {
ref path,
reviews,
ceiling,
} => write!(
formatter,
"{path}: the pull request holds {reviews} reviews, past the ceiling of \
{ceiling}; the list was not returned, because a prefix of it is not \
the review history"
),
Self::MalformedResponse {
ref path,
ref source,
} => {
write!(formatter, "{path}: not the expected JSON: {source}")
}
Self::PayloadNotSent { ref reason } => {
write!(formatter, "the review payload was not sent: {reason}")
}
Self::Staging {
ref path,
ref source,
} => {
write!(
formatter,
"{path}: the payload could not be staged: {source}"
)
}
Self::Unauthorized {
ref what,
status,
ref reason,
} => write!(
formatter,
"{what} was refused by a credential that cannot reach the resource \
(HTTP {status}): {reason:?}"
),
Self::MovedRepository {
ref requested,
ref canonical,
} => write!(
formatter,
"the repository {requested} moved to {canonical}; the subject identity is not \
re-pointed, so nothing is reviewed under the canonical name until the caller \
names it"
),
Self::DiffUnavailable {
ref path,
ref reason,
} => write!(
formatter,
"{path}: the diff could not be produced, so the coverage is incomplete: {reason:?}"
),
Self::DiffFileCeiling {
ref path,
files,
ceiling,
} => write!(
formatter,
"{path}: the pull request changes {files} files, past the ceiling of {ceiling}; \
the inventory was not returned, because a prefix of it is not the diff"
),
Self::DiffTooLarge {
ref path,
bytes,
ceiling,
} => write!(
formatter,
"{path}: the changed files carry {bytes} bytes of patch text, past the ceiling \
of {ceiling}; the inventory was not returned, because a prefix of it is not \
the diff"
),
}
}
}
impl std::error::Error for GhError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
#[cfg(feature = "process")]
Self::Process(ref source) => Some(source),
Self::NoRunner
| Self::Repository { .. }
| Self::CommitId { .. }
| Self::Event { .. }
| Self::Marker { .. }
| Self::Transport { .. }
| Self::Deadline { .. }
| Self::Response { .. }
| Self::TruncatedResponse { .. }
| Self::ReviewCeiling { .. }
| Self::Unauthorized { .. }
| Self::MovedRepository { .. }
| Self::DiffUnavailable { .. }
| Self::DiffFileCeiling { .. }
| Self::DiffTooLarge { .. }
| Self::MalformedResponse { .. }
| Self::PayloadNotSent { .. }
| Self::Staging { .. } => None,
}
}
}
#[cfg(feature = "script")]
impl From<GhError> for crate::script::FlowError {
fn from(source: GhError) -> Self {
if let Some(rejection) = source.credential_rejection("gh") {
return crate::script::FlowError::Bot {
at: std::sync::Arc::from(""),
source: Box::new(rejection),
};
}
let certainty = match source {
#[cfg(feature = "process")]
GhError::Process(crate::rt::process::ProcessRunError::AfterStart { .. }) => {
crate::error::DispatchCertainty::Unsettled
}
#[cfg(feature = "process")]
GhError::Process(
crate::rt::process::ProcessRunError::Refused
| crate::rt::process::ProcessRunError::NotStarted { .. },
) => crate::error::DispatchCertainty::Refused,
GhError::Repository { .. }
| GhError::CommitId { .. }
| GhError::Event { .. }
| GhError::Marker { .. }
| GhError::NoRunner
| GhError::PayloadNotSent { .. }
| GhError::Staging { .. } => crate::error::DispatchCertainty::Refused,
_ => crate::error::DispatchCertainty::NotDelivered,
};
let cause = match certainty {
crate::error::DispatchCertainty::Unsettled => {
crate::error::BotError::EffectIndeterminate {
domain: String::from("gh"),
cause: source.to_string(),
}
}
_ => crate::error::BotError::DomainError {
domain: String::from("gh"),
certainty,
cause: source.to_string(),
},
};
crate::script::FlowError::Bot {
at: std::sync::Arc::from(""),
source: Box::new(cause),
}
}
}
#[derive(Debug)]
pub struct GhQuery {
gh: Gh,
rest: Vec<String>,
caps: Vec<Cap>,
}
impl GhQuery {
#[must_use]
pub fn new(gh: Gh, rest: impl IntoIterator<Item = String>) -> Self {
Self {
caps: gh.required_caps().to_vec(),
gh,
rest: rest.into_iter().collect(),
}
}
}
impl verb::Query for GhQuery {
type Input = ();
type Output = GhOutcome;
fn required_caps(&self) -> &[Cap] {
&self.caps
}
async fn query(&self, call: (Auth, &())) -> Result<GhOutcome, BotError> {
call.0.check(self.required_caps())?;
self.run().await
}
fn domain_id(&self) -> &str {
"gh::api"
}
}
impl GhQuery {
#[cfg(not(feature = "process"))]
async fn run(&self) -> Result<GhOutcome, BotError> {
let rest: Vec<&str> = self.rest.iter().map(String::as_str).collect();
let args = self.gh.args_for(&rest);
match self.gh.call(&args).await {
Err(GhError::NoRunner) | Ok(GhOutcome { .. }) => Err(BotError::DomainError {
domain: String::from("gh::api"),
certainty: DispatchCertainty::Refused,
cause: GhError::NoRunner.to_string(),
}),
Err(source) => Err(BotError::DomainError {
domain: String::from("gh::api"),
certainty: DispatchCertainty::NotDelivered,
cause: source.to_string(),
}),
}
}
#[cfg(feature = "process")]
async fn run(&self) -> Result<GhOutcome, BotError> {
let rest: Vec<&str> = self.rest.iter().map(String::as_str).collect();
let args = self.gh.args_for(&rest);
match self.gh.call(&args).await {
Ok(outcome) => Ok(outcome),
Err(GhError::Process(crate::rt::process::ProcessRunError::Refused))
| Err(GhError::Process(crate::rt::process::ProcessRunError::NotStarted { .. })) => {
Err(BotError::DomainError {
domain: String::from("gh::api"),
certainty: DispatchCertainty::Refused,
cause: format!("the GitHub client did not start: {}", self.gh.program),
})
}
Err(GhError::Process(crate::rt::process::ProcessRunError::AfterStart { source })) => {
Err(BotError::EffectIndeterminate {
domain: String::from("gh::api"),
cause: format!("the GitHub client started but did not settle: {source}"),
})
}
Err(source) => Err(BotError::DomainError {
domain: String::from("gh::api"),
certainty: DispatchCertainty::NotDelivered,
cause: source.to_string(),
}),
}
}
}
#[derive(Debug)]
pub struct PrSnapshotSource {
gh: Gh,
pull: PullRequest,
caps: Vec<Cap>,
}
impl PrSnapshotSource {
#[must_use]
pub fn new(gh: Gh, pull: PullRequest) -> Self {
Self {
caps: gh.required_caps().to_vec(),
gh,
pull,
}
}
}
impl verb::Observe for PrSnapshotSource {
type Output = PrSnapshot;
fn required_caps(&self) -> &[Cap] {
&self.caps
}
async fn poll(&self, call: (Auth, ())) -> Result<PrSnapshot, BotError> {
call.0.check(self.required_caps())?;
match self.gh.snapshot(&self.pull).await {
Ok(snapshot) => Ok(snapshot),
Err(source) => Err(match source.credential_rejection(self.domain_id()) {
Some(rejection) => rejection,
None => BotError::DomainError {
domain: self.domain_id().into(),
certainty: DispatchCertainty::NotDelivered,
cause: source.to_string(),
},
}),
}
}
fn domain_id(&self) -> &str {
"gh::pr_snapshot"
}
}